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_SECRET=change-me-to-a-random-string-at-least-32-chars
JWT_EXPIRES_IN=24h JWT_EXPIRES_IN=24h
PORT=3000 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 TeacherWorkspacePage = lazy(() => import('./pages/TeacherWorkspace'));
const NotificationsPage = lazy(() => import('./pages/Notifications')); const NotificationsPage = lazy(() => import('./pages/Notifications'));
const IntegrationConfigPage = lazy(() => import('./pages/IntegrationConfig')); const IntegrationConfigPage = lazy(() => import('./pages/IntegrationConfig'));
const AiConfigPage = lazy(() => import('./pages/AiConfig'));
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
@@ -274,6 +275,15 @@ const App: React.FC = () => {
</PermissionRoute> </PermissionRoute>
} }
/> />
<Route
path="ai-config"
element={
<PermissionRoute permission="ai:config:read">
<AiConfigPage />
</PermissionRoute>
}
/>
</Route> </Route>
</Routes> </Routes>
</Suspense> </Suspense>

View File

@@ -26,6 +26,7 @@ import {
LaptopOutlined, LaptopOutlined,
BellOutlined, BellOutlined,
ApiOutlined, ApiOutlined,
RobotOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { usePermission } from '../hooks/usePermission'; import { usePermission } from '../hooks/usePermission';
import api from '../api'; import api from '../api';
@@ -115,6 +116,7 @@ const allMenuItems: MenuItemType[] = [
{ key: '/roles', icon: <SafetyOutlined />, label: '角色管理', permission: 'role:view' }, { key: '/roles', icon: <SafetyOutlined />, label: '角色管理', permission: 'role:view' },
{ key: '/permissions', icon: <KeyOutlined />, label: '权限一览', permission: 'role:view' }, { key: '/permissions', icon: <KeyOutlined />, label: '权限一览', permission: 'role:view' },
{ key: '/integration-config', icon: <ApiOutlined />, label: '钉钉集成配置', permission: 'integration:read' }, { 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' }, { 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": { "scripts": {
"dev": "SEED_DEV=true nest start --watch -p tsconfig.build.json", "dev": "SEED_DEV=true nest start --watch -p tsconfig.build.json",
"build": "nest build -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", "format": "oxfmt",
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
@@ -23,6 +22,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@casl/ability": "^7.0.1",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4", "@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1", "@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 { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter'; 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 { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { import {
Student, Student,
@@ -40,11 +40,14 @@ import {
ResultArchive, ResultArchive,
ArchiveAttachment, ArchiveAttachment,
StudentDingMapping, StudentDingMapping,
AiConfig,
} from './entities'; } from './entities';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { AuthorizationModule } from './authorization';
import { RbacModule } from './rbac/rbac.module'; import { RbacModule } from './rbac/rbac.module';
import { StudentsModule } from './students/students.module'; import { StudentsModule } from './students/students.module';
import { PermissionGuard } from './auth/guards/permission.guard'; import { PermissionGuard } from './auth/guards/permission.guard';
import { PoliciesGuard } from './authorization/guards/policies.guard';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RoomsModule } from './rooms/rooms.module'; import { RoomsModule } from './rooms/rooms.module';
import { OccupanciesModule } from './occupancies/occupancies.module'; import { OccupanciesModule } from './occupancies/occupancies.module';
@@ -64,6 +67,8 @@ import { NotificationsModule } from './notifications/notifications.module';
import { ArchiveModule } from './archive/archive.module'; import { ArchiveModule } from './archive/archive.module';
import { ExpenseTypesModule } from './expense-types/expense-types.module'; import { ExpenseTypesModule } from './expense-types/expense-types.module';
import { DatabaseMigrationsModule } from './database/database-migrations.module'; import { DatabaseMigrationsModule } from './database/database-migrations.module';
import { AgentToolsModule } from './agent-tools';
import { AiConfigModule } from './ai-config/ai-config.module';
import { import {
IntegrationConfig, IntegrationConfig,
@@ -73,6 +78,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
@Module({ @Module({
imports: [ imports: [
AuthorizationModule,
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot([ ThrottlerModule.forRoot([
{ {
@@ -84,7 +90,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
imports: [ConfigModule], imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService): any => { useFactory: (config: ConfigService): TypeOrmModuleOptions => {
const dbType = config.get('DB_TYPE', 'sqlite'); const dbType = config.get('DB_TYPE', 'sqlite');
const allEntities = [ const allEntities = [
Student, Student,
@@ -124,6 +130,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
StudentDingMapping, StudentDingMapping,
IntegrationConfig, IntegrationConfig,
IntegrationConfigDetail, IntegrationConfigDetail,
AiConfig,
]; ];
if (dbType === 'mysql') { if (dbType === 'mysql') {
return { return {
@@ -131,8 +138,8 @@ import { IntegrationConfigModule } from './integration/config/config.module';
host: config.get('DB_HOST', 'localhost'), host: config.get('DB_HOST', 'localhost'),
port: config.get<number>('DB_PORT', 3306), port: config.get<number>('DB_PORT', 3306),
username: config.get('DB_USERNAME', 'root'), username: config.get('DB_USERNAME', 'root'),
password: config.get('DB_PASSWORD', ''), password: config.get<string>('DB_PASSWORD', ''),
database: config.get('DB_DATABASE', 'dorm_billing'), database: config.get<string>('DB_DATABASE', 'dorm_billing'),
entities: allEntities, entities: allEntities,
synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false', synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',
charset: 'utf8mb4', charset: 'utf8mb4',
@@ -140,7 +147,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
} }
return { return {
type: 'better-sqlite3' as const, type: 'better-sqlite3' as const,
database: config.get('DB_DATABASE', 'dorm_billing.db'), database: config.get<string>('DB_DATABASE', 'dorm_billing.db'),
entities: allEntities, entities: allEntities,
synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false', synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',
}; };
@@ -167,12 +174,15 @@ import { IntegrationConfigModule } from './integration/config/config.module';
NotificationsModule, NotificationsModule,
ArchiveModule, ArchiveModule,
IntegrationConfigModule, IntegrationConfigModule,
AgentToolsModule,
ExpenseTypesModule, ExpenseTypesModule,
AiConfigModule,
], ],
providers: [ providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard }, { provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionGuard }, { provide: APP_GUARD, useClass: PermissionGuard },
{ provide: APP_GUARD, useClass: PoliciesGuard },
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -15,6 +15,9 @@ describe('AttendanceController — DingTalk import scope', () => {
const logService = { const logService = {
log: jest.fn(), log: jest.fn(),
}; };
const authzService = {
can: jest.fn().mockReturnValue(false),
};
let controller: AttendanceController; let controller: AttendanceController;
@@ -24,6 +27,7 @@ describe('AttendanceController — DingTalk import scope', () => {
attendanceService as unknown as AttendanceService, attendanceService as unknown as AttendanceService,
importService as unknown as AttendanceImportService, importService as unknown as AttendanceImportService,
logService as unknown as OperationLogsService, logService as unknown as OperationLogsService,
authzService as never,
); );
importService.importFromDingTalk.mockResolvedValue({ importService.importFromDingTalk.mockResolvedValue({
success: true, 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 () => { it('allows class managers to choose any importable class and supply explicit DingTalk users', async () => {
attendanceService.getImportableClasses.mockResolvedValue([{ classId: 1, className: '一班' }]); attendanceService.getImportableClasses.mockResolvedValue([{ classId: 1, className: '一班' }]);
authzService.can.mockReturnValue(true);
await expect( await expect(
controller.getDingTalkImportClasses({ controller.getDingTalkImportClasses({
user: { user: {

View File

@@ -35,6 +35,8 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils'; import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator'; import { RequirePermission } from '../auth/decorators/permission.decorator';
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
import type { AuthenticatedUser } from '../authorization';
/** SSE event shape for @Sse() decorator */ /** SSE event shape for @Sse() decorator */
interface SseEvent { interface SseEvent {
@@ -47,8 +49,9 @@ interface SseEvent {
interface RequestUser { interface RequestUser {
id: number; id: number;
username: string; username: string;
permissions?: string[]; permissions: string[];
isSuperAdmin?: boolean; isSuperAdmin: boolean;
roles: string[];
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -58,6 +61,7 @@ export class AttendanceController {
private readonly service: AttendanceService, private readonly service: AttendanceService,
private readonly importService: AttendanceImportService, private readonly importService: AttendanceImportService,
private readonly logService: OperationLogsService, private readonly logService: OperationLogsService,
private readonly authz: AuthorizationService,
) {} ) {}
private getTodayDateOnly(): string { private getTodayDateOnly(): string {
@@ -68,16 +72,20 @@ export class AttendanceController {
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
} }
private canManageAllAttendance(user: RequestUser): boolean { private canManageAllAttendance(req: { user: RequestUser }): boolean {
return user.isSuperAdmin === true || user.permissions?.includes('class:edit') === true; 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) { private getAccessibleClassIds(req: { user: RequestUser }) {
return this.service.getAccessibleClassIds(user.id, this.canManageAllAttendance(user)); return this.service.getAccessibleClassIds(req.user.id, this.canManageAllAttendance(req));
} }
private assertClassAccess(user: RequestUser, classId: number) { private assertClassAccess(req: { user: RequestUser }, classId: number) {
return this.service.assertClassAccess(user.id, classId, this.canManageAllAttendance(user)); return this.service.assertClassAccess(req.user.id, classId, this.canManageAllAttendance(req));
} }
// ── Batch create attendance records ── // ── Batch create attendance records ──
@@ -124,8 +132,8 @@ export class AttendanceController {
@Res() res: Response, @Res() res: Response,
@Request() req: { user: RequestUser }, @Request() req: { user: RequestUser },
) { ) {
if (query.classId) await this.assertClassAccess(req.user, query.classId); if (query.classId) await this.assertClassAccess(req, query.classId);
const classIds = await this.getAccessibleClassIds(req.user); const classIds = await this.getAccessibleClassIds(req);
const records = await this.service.findAllForExport(query, classIds); const records = await this.service.findAllForExport(query, classIds);
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
@@ -176,8 +184,8 @@ export class AttendanceController {
@Get('attendance-records') @Get('attendance-records')
@RequirePermission('attendance:view') @RequirePermission('attendance:view')
async findAll(@Query() query: QueryAttendanceRecordsDto, @Request() req: { user: RequestUser }) { async findAll(@Query() query: QueryAttendanceRecordsDto, @Request() req: { user: RequestUser }) {
if (query.classId) await this.assertClassAccess(req.user, query.classId); if (query.classId) await this.assertClassAccess(req, query.classId);
return this.service.findAll(query, await this.getAccessibleClassIds(req.user)); return this.service.findAll(query, await this.getAccessibleClassIds(req));
} }
// ── Update a single attendance record ── // ── Update a single attendance record ──
@@ -228,7 +236,7 @@ export class AttendanceController {
@Get('attendance-records/classes') @Get('attendance-records/classes')
@RequirePermission('attendance:view') @RequirePermission('attendance:view')
async getClasses(@Request() req: { user: RequestUser }) { 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 ── // ── Attendance summary ──
@@ -238,8 +246,8 @@ export class AttendanceController {
@Query() query: AttendanceSummaryQueryDto, @Query() query: AttendanceSummaryQueryDto,
@Request() req: { user: RequestUser }, @Request() req: { user: RequestUser },
) { ) {
if (query.classId) await this.assertClassAccess(req.user, query.classId); if (query.classId) await this.assertClassAccess(req, query.classId);
return this.service.getSummary(query, await this.getAccessibleClassIds(req.user)); return this.service.getSummary(query, await this.getAccessibleClassIds(req));
} }
// ── Attendance calendar ── // ── Attendance calendar ──
@@ -249,7 +257,7 @@ export class AttendanceController {
@Query() query: AttendanceCalendarQueryDto, @Query() query: AttendanceCalendarQueryDto,
@Request() req: { user: RequestUser }, @Request() req: { user: RequestUser },
) { ) {
await this.assertClassAccess(req.user, query.classId); await this.assertClassAccess(req, query.classId);
return this.service.getCalendar(query); return this.service.getCalendar(query);
} }
@@ -257,8 +265,8 @@ export class AttendanceController {
@Get('ding-attendance-raw') @Get('ding-attendance-raw')
@RequirePermission('attendance:view') @RequirePermission('attendance:view')
async getDingRaw(@Query() query: QueryDingRawDto, @Request() req: { user: RequestUser }) { async getDingRaw(@Query() query: QueryDingRawDto, @Request() req: { user: RequestUser }) {
if (query.classId) await this.assertClassAccess(req.user, query.classId); if (query.classId) await this.assertClassAccess(req, query.classId);
return this.service.getDingRaw(query, await this.getAccessibleClassIds(req.user)); return this.service.getDingRaw(query, await this.getAccessibleClassIds(req));
} }
// ── Match a dingtalk record to a student ── // ── Match a dingtalk record to a student ──
@@ -293,11 +301,8 @@ export class AttendanceController {
@Res() res: Response, @Res() res: Response,
@Request() req: any, @Request() req: any,
) { ) {
if (query.classId) await this.assertClassAccess(req.user, query.classId); if (query.classId) await this.assertClassAccess(req, query.classId);
const reportData = await this.service.getReport( const reportData = await this.service.getReport(query, await this.getAccessibleClassIds(req));
query,
await this.getAccessibleClassIds(req.user),
);
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('考勤统计报表'); const ws = workbook.addWorksheet('考勤统计报表');
@@ -363,7 +368,7 @@ export class AttendanceController {
return this.service.getAlerts( return this.service.getAlerts(
days ? +days : 14, days ? +days : 14,
threshold ? +threshold : 3, 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') @Get('attendance-records/import/dingtalk/classes')
@RequirePermission('attendance:create') @RequirePermission('attendance:create')
getDingTalkImportClasses(@Request() req: { user: RequestUser }) { 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') @RequirePermission('attendance:create')
async importFromDingTalk(@Body() dto: DingTalkImportDto, @Request() req: { user: RequestUser }) { async importFromDingTalk(@Body() dto: DingTalkImportDto, @Request() req: { user: RequestUser }) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const canManageAll = this.canManageAllAttendance(req.user); const canManageAll = this.canManageAllAttendance(req);
let userIds: string[]; let userIds: string[];
if (dto.users) { if (dto.users) {

View File

@@ -1,50 +1,223 @@
import { PermissionGuard } from './permission.guard'; import { PermissionGuard } from './permission.guard';
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
import { CaslAction, permissionCodeSubject } from '../../authorization/casl.constants';
describe('PermissionGuard', () => { 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() {}, getHandler: () => function handler() {},
getClass: () => class Controller {}, getClass: () => class Controller {},
switchToHttp: () => ({ getRequest: () => ({ user }) }), switchToHttp: () => ({ getRequest: () => ({ user }) }),
}) as never; } as never);
}
// -----------------------------------------------------------------------
// @Public / @Authenticated / undeclared
// -----------------------------------------------------------------------
it('denies routes that forgot to declare permissions', () => { it('denies routes that forgot to declare permissions', () => {
const reflector = { expect(createContext(undefined)).toBe(false);
getAllAndOverride: jest.fn().mockReturnValue(false), expect(createContext({ permissions: [], isSuperAdmin: false })).toBe(false);
getAllAndMerge: jest.fn().mockReturnValue(undefined),
};
const guard = new PermissionGuard(reflector as never);
expect(guard.canActivate(createContext({ permissions: ['dashboard:view'] }))).toBe(false);
}); });
it('allows explicitly public routes without a user', () => { it('allows explicitly public routes without a user', () => {
const reflector = { expect(createContext(undefined, { isPublic: true })).toBe(true);
getAllAndOverride: jest.fn().mockReturnValue(true),
getAllAndMerge: jest.fn(),
};
const guard = new PermissionGuard(reflector as never);
expect(guard.canActivate(createContext(undefined))).toBe(true);
}); });
it('allows authenticated-only routes for logged-in users without requiring profile:view', () => { it('allows authenticated-only routes for logged-in users', () => {
const reflector = { expect(
getAllAndOverride: jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true), createContext({ permissions: [], isSuperAdmin: false }, { authenticatedOnly: true }),
getAllAndMerge: jest.fn(), ).toBe(true);
};
const guard = new PermissionGuard(reflector as never);
expect(guard.canActivate(createContext({ permissions: [] }))).toBe(true);
}); });
it('denies authenticated-only routes when no authenticated user is present', () => { it('denies authenticated-only routes when no user is present', () => {
const reflector = { expect(createContext(undefined, { authenticatedOnly: true })).toBe(false);
getAllAndOverride: jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true), });
getAllAndMerge: jest.fn(),
};
const guard = new PermissionGuard(reflector as never);
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 { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; 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 { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { PERMISSION_KEY } from '../decorators/permission.decorator'; import { PERMISSION_KEY } from '../decorators/permission.decorator';
import { AUTHENTICATED_KEY } from '../decorators/authenticated.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。 * Authorization is via CASL exact-code matching: each permission code
* 建议配合 lint 规则确保无遗漏。 * 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() @Injectable()
export class PermissionGuard implements CanActivate { export class PermissionGuard implements CanActivate {
constructor(private reflector: Reflector) {} constructor(
private reflector: Reflector,
private abilityFactory: CaslAbilityFactory,
) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
// 1. @Public() 豁免 // 1. @Public() exemption
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),
]); ]);
if (isPublic) return true; if (isPublic) return true;
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest<AuthorizationRequest>();
const user = request.user; 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, [ const authenticatedOnly = this.reflector.getAllAndOverride<boolean>(AUTHENTICATED_KEY, [
context.getHandler(), context.getHandler(),
context.getClass(), 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, [ const requiredPermissions = this.reflector.getAllAndMerge<string[]>(PERMISSION_KEY, [
context.getHandler(), context.getHandler(),
context.getClass(), 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; if (!user || !user.permissions || !Array.isArray(user.permissions)) return false;
// 5. OR 匹配:用户拥有 requiredPermissions 中任一权限即可通过 // 8. CASL exact-code check — maps each required code to an exact PermissionCode subject
return requiredPermissions.some((p) => user.permissions.includes(p)); 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 { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity'; import { NotificationType } from '../entities/notification.entity';
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
import { AuthorizationService, CaslAction, SubjectName, AuthenticatedUser } from '../authorization';
interface RequestUser {
id: number;
permissions?: string[];
isSuperAdmin?: boolean;
}
interface AuthenticatedRequest { interface AuthenticatedRequest {
user: RequestUser; user: AuthenticatedUser;
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -48,11 +43,14 @@ export class ClassesController {
private readonly service: ClassesService, private readonly service: ClassesService,
private readonly logService: OperationLogsService, private readonly logService: OperationLogsService,
private readonly notificationsService: NotificationsService, private readonly notificationsService: NotificationsService,
private readonly authz: AuthorizationService,
) {} ) {}
private assertReadAccess(req: AuthenticatedRequest, classId: number) { private assertReadAccess(req: AuthenticatedRequest, classId: number) {
// Legacy: Manage (super_admin) or Update (class:edit) grants broad class access
const canManageAll = 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); return this.service.assertClassAccess(req.user.id, classId, canManageAll);
} }
@@ -61,7 +59,8 @@ export class ClassesController {
async findAll(@Query() query: QueryClassDto, @Request() req: AuthenticatedRequest) { async findAll(@Query() query: QueryClassDto, @Request() req: AuthenticatedRequest) {
const classIds = await this.service.getAccessibleClassIds( const classIds = await this.service.getAccessibleClassIds(
req.user.id, 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); return this.service.findAll(query, classIds);
} }

View File

@@ -1,36 +1,45 @@
import { Controller, Get, Query, Request, UseGuards } from '@nestjs/common'; import { Controller, Get, Query, Request, UseGuards } from '@nestjs/common';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import {
AuthorizationService,
CaslAction,
SubjectName,
} from '../authorization';
import { RequirePermission } from '../auth/decorators/permission.decorator'; import { RequirePermission } from '../auth/decorators/permission.decorator';
interface RequestUser { interface RequestUser {
id: number; id: number;
username: string; username: string;
permissions?: string[]; permissions: string[];
isSuperAdmin?: boolean; isSuperAdmin: boolean;
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@RequirePermission('dashboard:view') @RequirePermission('dashboard:view')
@Controller('dashboard') @Controller('dashboard')
export class DashboardController { 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 ( return (
user.isSuperAdmin === true || ability.can(CaslAction.Manage, SubjectName.Dashboard) ||
user.permissions?.includes('dashboard:manage') === true || ability.can(CaslAction.Update, SubjectName.Class)
user.permissions?.includes('class:edit') === true
); );
} }
private getAccessibleClassIds(user: RequestUser) { private getAccessibleClassIds(req: { user: RequestUser }) {
return this.service.getAccessibleClassIds(user.id, this.canManageAllDashboard(user)); return this.service.getAccessibleClassIds(req.user.id, this.canManageAllDashboard(req));
} }
@Get('stats') @Get('stats')
async getStats(@Request() req: { user: RequestUser }) { 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') @Get('gantt')
@@ -60,7 +69,7 @@ export class DashboardController {
@Get('class-attendance-ranking') @Get('class-attendance-ranking')
async getClassAttendanceRanking(@Request() req: { user: RequestUser }) { 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') @Get('classroom-occupancy')

View File

@@ -9,10 +9,97 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
constructor(private readonly dataSource: DataSource) {} constructor(private readonly dataSource: DataSource) {}
async onApplicationBootstrap(): Promise<void> { async onApplicationBootstrap(): Promise<void> {
await this.ensureAiConfigTable();
await this.backfillOrganizations(); await this.backfillOrganizations();
await this.normalizeClassDates(); 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> { private async backfillOrganizations(): Promise<void> {
const runner = this.dataSource.createQueryRunner(); const runner = this.dataSource.createQueryRunner();
await runner.connect(); 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 { ResultArchive } from './result-archive.entity';
export { ArchiveAttachment } from './archive-attachment.entity'; export { ArchiveAttachment } from './archive-attachment.entity';
export { StudentDingMapping } from './student-ding-mapping.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 { IsString, IsNotEmpty, IsOptional, IsArray, IsInt } from 'class-validator';
import { Type } from 'class-transformer';
export class CreateNotificationDto { export class CreateNotificationDto {
@IsArray() @IsArray()
@@ -24,10 +25,12 @@ export class CreateNotificationDto {
export class NotificationQueryDto { export class NotificationQueryDto {
@IsOptional() @IsOptional()
@Type(() => Number)
@IsInt() @IsInt()
after?: number; after?: number;
@IsOptional() @IsOptional()
@Type(() => Number)
@IsInt() @IsInt()
limit?: number; 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:view', name: '查看部门', group: 'department' },
{ code: 'department:edit', name: '编辑部门', group: 'department' }, { code: 'department:edit', name: '编辑部门', group: 'department' },
{ code: 'department:delete', 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<{ export const PRESET_ROLES: Array<{

View File

@@ -10,6 +10,11 @@ import {
UseGuards, UseGuards,
Request, Request,
} from '@nestjs/common'; } from '@nestjs/common';
import {
AuthorizationService,
CaslAction,
SubjectName,
} from '../authorization';
import { SchedulesService } from './schedules.service'; import { SchedulesService } from './schedules.service';
import { import {
CreateScheduleDto, CreateScheduleDto,
@@ -28,8 +33,8 @@ import { RequirePermission } from '../auth/decorators/permission.decorator';
interface RequestUser { interface RequestUser {
id: number; id: number;
username: string; username: string;
permissions?: string[]; permissions: string[];
isSuperAdmin?: boolean; isSuperAdmin: boolean;
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -39,13 +44,15 @@ export class SchedulesController {
private readonly service: SchedulesService, private readonly service: SchedulesService,
private readonly logService: OperationLogsService, private readonly logService: OperationLogsService,
private readonly notificationsService: NotificationsService, 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 ( return (
user.isSuperAdmin === true || ability.can(CaslAction.Manage, SubjectName.Schedule) ||
user.permissions?.includes('schedule:edit') === true || ability.can(CaslAction.Update, SubjectName.Class)
user.permissions?.includes('class:edit') === true
); );
} }
@@ -54,7 +61,7 @@ export class SchedulesController {
async findAll(@Query() query: QueryScheduleDto, @Request() req: { user: RequestUser }) { async findAll(@Query() query: QueryScheduleDto, @Request() req: { user: RequestUser }) {
const classIds = await this.service.getAccessibleClassIds( const classIds = await this.service.getAccessibleClassIds(
req.user.id, req.user.id,
this.canManageAllSchedules(req.user), this.canManageAllSchedules(req),
); );
return this.service.findAll(query, classIds); return this.service.findAll(query, classIds);
} }
@@ -64,7 +71,7 @@ export class SchedulesController {
async getWeeklyView(@Query() query: WeeklyViewQueryDto, @Request() req: { user: RequestUser }) { async getWeeklyView(@Query() query: WeeklyViewQueryDto, @Request() req: { user: RequestUser }) {
const classIds = await this.service.getAccessibleClassIds( const classIds = await this.service.getAccessibleClassIds(
req.user.id, req.user.id,
this.canManageAllSchedules(req.user), this.canManageAllSchedules(req),
); );
return this.service.getWeeklyView(query, classIds); return this.service.getWeeklyView(query, classIds);
} }
@@ -74,7 +81,7 @@ export class SchedulesController {
async getClassTeachers(@Param('classId') classId: string, @Request() req: { user: RequestUser }) { async getClassTeachers(@Param('classId') classId: string, @Request() req: { user: RequestUser }) {
const classIds = await this.service.getAccessibleClassIds( const classIds = await this.service.getAccessibleClassIds(
req.user.id, req.user.id,
this.canManageAllSchedules(req.user), this.canManageAllSchedules(req),
); );
if (classIds && !classIds.includes(+classId)) return []; if (classIds && !classIds.includes(+classId)) return [];
return this.service.getClassTeachers(+classId); 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 { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils'; import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator'; import { RequirePermission } from '../auth/decorators/permission.decorator';
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
import type { AuthenticatedUser } from '../authorization';
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
interface AuthenticatedRequest {
user: AuthenticatedUser;
}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('students') @Controller('students')
export class StudentsController { export class StudentsController {
@@ -35,13 +41,14 @@ export class StudentsController {
private service: StudentsService, private service: StudentsService,
private logService: OperationLogsService, private logService: OperationLogsService,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>, @InjectRepository(Organization) private organizationRepo: Repository<Organization>,
private authz: AuthorizationService,
) {} ) {}
private canManageAllStudents(user: { isSuperAdmin?: boolean; permissions?: string[] }): boolean { private canManageAllStudents(req: AuthenticatedRequest): boolean {
return ( return (
user.isSuperAdmin === true || this.authz.can(req, CaslAction.Manage, SubjectName.Student) ||
user.permissions?.includes('student:edit') === true || // Legacy: class:edit grants broad student access for teacher scoping
user.permissions?.includes('class:edit') === true this.authz.can(req, CaslAction.Update, SubjectName.Class)
); );
} }
@@ -52,11 +59,11 @@ export class StudentsController {
@Query('status') status: string | undefined, @Query('status') status: string | undefined,
@Query('includeArchived') includeArchived: string | undefined, @Query('includeArchived') includeArchived: string | undefined,
@Query('organizationId') organizationId: 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( const classIds = await this.service.getAccessibleClassIds(
req.user.id, req.user.id,
this.canManageAllStudents(req.user), this.canManageAllStudents(req),
); );
return this.service.findAll( return this.service.findAll(
{ {
@@ -78,7 +85,7 @@ export class StudentsController {
) { ) {
const classIds = await this.service.getAccessibleClassIds( const classIds = await this.service.getAccessibleClassIds(
req.user.id, req.user.id,
this.canManageAllStudents(req.user), this.canManageAllStudents(req),
); );
const students = await this.service.findAll( const students = await this.service.findAll(
{ includeArchived: includeArchived === 'true' }, { includeArchived: includeArchived === 'true' },

View File

@@ -7,6 +7,7 @@ import { ClassStudent } from '../entities/class-student.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity';
import { ClassTeacher } from '../entities/class-teacher.entity'; import { ClassTeacher } from '../entities/class-teacher.entity';
import { StudentsService } from './students.service'; import { StudentsService } from './students.service';
import { StudentAccessScopeFactory } from './student-access-scope.factory';
import { StudentsController } from './students.controller'; import { StudentsController } from './students.controller';
@Module({ @Module({
@@ -21,7 +22,7 @@ import { StudentsController } from './students.controller';
]), ]),
], ],
controllers: [StudentsController], controllers: [StudentsController],
providers: [StudentsService], providers: [StudentsService, StudentAccessScopeFactory],
exports: [StudentsService], exports: [StudentsService, StudentAccessScopeFactory],
}) })
export class StudentsModule {} export class StudentsModule {}

View File

@@ -8,6 +8,7 @@ import { ClassTeacher } from '../entities/class-teacher.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity';
import { Organization } from '../entities/organization.entity'; import { Organization } from '../entities/organization.entity';
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto'; import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
import type { StudentAccessScope } from './student-access-scope';
@Injectable() @Injectable()
export class StudentsService { export class StudentsService {
@@ -224,7 +225,7 @@ export class StudentsService {
if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone; if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone;
if (row.supervisor) updates.supervisor = row.supervisor; if (row.supervisor) updates.supervisor = row.supervisor;
if (row.organizationId) updates.organizationId = row.organizationId; 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++; matched++;
} }
return { return {
@@ -302,4 +303,228 @@ export class StudentsService {
return { student, enrollments: comparison }; 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", "version": "0.0.1",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@casl/ability": "^7.0.1",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4", "@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
@@ -1039,6 +1040,18 @@
"url": "https://github.com/sponsors/Borewit" "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": { "node_modules/@colors/colors": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmmirror.com/@colors/colors/-/colors-1.5.0.tgz", "resolved": "https://registry.npmmirror.com/@colors/colors/-/colors-1.5.0.tgz",
@@ -6169,6 +6182,41 @@
"url": "https://opencollective.com/eslint" "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": { "node_modules/@ungap/structured-clone": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.2.tgz",