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

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