feat(iam): 实现 RBAC + 视口配置 + DataScope

- 新增 iam_role_viewports 表(4 层视口模型:L1 导航/L2 路由/L3 组件/L4 数据)
- iam_users 添加 data_scope 字段(6 级:self/class/grade/school/district/all)
- JWT payload 包含 dataScope,注册时自动分配 teacher 角色
- 新增 getEffectivePermissions(多角色权限去重)
- 新增 getUserViewports(按权限过滤视口)
- 新增 RbacController:GET /iam/viewports, /iam/permissions/effective, /iam/roles, /iam/permissions
- 添加 7 个权限点 + 12 条角色权限映射 + 7 个视口种子数据
This commit is contained in:
SpecialX
2026-07-09 00:35:34 +08:00
parent 5759b09c9f
commit a2f0ca26ae
6 changed files with 267 additions and 39 deletions

View File

@@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS `iam_users` (
`password_hash` VARCHAR(255) NOT NULL,
`name` VARCHAR(100) NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
`data_scope` ENUM('self','class','grade','school','district','all') NOT NULL DEFAULT 'self',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
@@ -52,7 +53,63 @@ CREATE TABLE IF NOT EXISTS `iam_refresh_tokens` (
INDEX `idx_iam_refresh_tokens_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 种子数据:默认 teacher 角色
-- 视口配置表4 层模型L1 导航 / L2 路由 / L3 组件 / L4 数据)
CREATE TABLE IF NOT EXISTS `iam_role_viewports` (
`id` CHAR(36) NOT NULL,
`role_id` CHAR(36) NOT NULL,
`viewport_key` VARCHAR(50) NOT NULL,
`label` VARCHAR(100) NOT NULL,
`route` VARCHAR(200) NOT NULL,
`icon` VARCHAR(50) NULL,
`sort_order` VARCHAR(10) NOT NULL DEFAULT '0',
`required_permission` VARCHAR(100) NULL,
`component_config` TEXT NULL,
PRIMARY KEY (`id`),
INDEX `idx_iam_role_viewports_role_id` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 种子数据:默认角色
INSERT IGNORE INTO `iam_roles` (`id`, `name`, `description`) VALUES
('00000000-0000-0000-0000-000000000001', 'teacher', '教师角色'),
('00000000-0000-0000-0000-000000000002', 'admin', '管理员角色');
-- 种子数据:权限点(固定 UUID 便于 role_permissions 引用)
INSERT IGNORE INTO `iam_permissions` (`id`, `name`, `resource`, `action`) VALUES
('00000000-0000-0000-0000-000000000101', 'classes:read', 'classes', 'read'),
('00000000-0000-0000-0000-000000000102', 'classes:create', 'classes', 'create'),
('00000000-0000-0000-0000-000000000103', 'classes:update', 'classes', 'update'),
('00000000-0000-0000-0000-000000000104', 'classes:delete', 'classes', 'delete'),
('00000000-0000-0000-0000-000000000201', 'iam:user:read', 'iam', 'user:read'),
('00000000-0000-0000-0000-000000000202', 'iam:user:manage', 'iam', 'user:manage'),
('00000000-0000-0000-0000-000000000203', 'iam:role:manage', 'iam', 'role:manage');
-- 种子数据teacher 角色权限classes CRUD + user:read
INSERT IGNORE INTO `iam_role_permissions` (`role_id`, `permission_id`) VALUES
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000101'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000102'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000103'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000104'),
('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000201');
-- 种子数据admin 角色权限(全部权限)
INSERT IGNORE INTO `iam_role_permissions` (`role_id`, `permission_id`) VALUES
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000101'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000102'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000103'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000104'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000201'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000202'),
('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000203');
-- 种子数据teacher 角色视口L1 导航)
INSERT IGNORE INTO `iam_role_viewports` (`id`, `role_id`, `viewport_key`, `label`, `route`, `icon`, `sort_order`, `required_permission`) VALUES
('00000000-0000-0000-0000-000000000a01', '00000000-0000-0000-0000-000000000001', 'dashboard', '仪表盘', '/dashboard', 'home', '0', NULL),
('00000000-0000-0000-0000-000000000a02', '00000000-0000-0000-0000-000000000001', 'classes', '班级管理', '/classes', 'users', '1', 'classes:read'),
('00000000-0000-0000-0000-000000000a03', '00000000-0000-0000-0000-000000000001', 'profile', '个人中心', '/profile', 'user', '9', NULL);
-- 种子数据admin 角色视口L1 导航)
INSERT IGNORE INTO `iam_role_viewports` (`id`, `role_id`, `viewport_key`, `label`, `route`, `icon`, `sort_order`, `required_permission`) VALUES
('00000000-0000-0000-0000-000000000b01', '00000000-0000-0000-0000-000000000002', 'dashboard', '仪表盘', '/dashboard', 'home', '0', NULL),
('00000000-0000-0000-0000-000000000b02', '00000000-0000-0000-0000-000000000002', 'classes', '班级管理', '/classes', 'users', '1', 'classes:read'),
('00000000-0000-0000-0000-000000000b03', '00000000-0000-0000-0000-000000000002', 'iam', '用户管理', '/iam/users', 'shield', '2', 'iam:user:read'),
('00000000-0000-0000-0000-000000000b04', '00000000-0000-0000-0000-000000000002', 'profile', '个人中心', '/profile', 'user', '9', NULL);

View File

@@ -1,10 +1,11 @@
import { Module } from "@nestjs/common";
import { IamController } from "./iam.controller.js";
import { RbacController } from "./rbac.controller.js";
import { IamService } from "./iam.service.js";
import { IamRepository } from "./iam.repository.js";
@Module({
controllers: [IamController],
controllers: [IamController, RbacController],
providers: [IamService, IamRepository],
})
export class IamModule {}

View File

@@ -7,8 +7,9 @@ import {
permissions,
rolePermissions,
refreshTokens,
roleViewports,
} from "./iam.schema.js";
import type { User, Role, Permission } from "./iam.schema.js";
import type { User, Role, Permission, RoleViewport } from "./iam.schema.js";
export class IamRepository {
async createUser(data: {
@@ -70,6 +71,39 @@ export class IamRepository {
return result.map((r) => r.iam_permissions);
}
async getUserViewports(userId: string): Promise<RoleViewport[]> {
const db = getDb();
const userRoleRows = await db
.select()
.from(userRoles)
.where(eq(userRoles.userId, userId));
const roleIds = userRoleRows.map((r) => r.roleId);
if (roleIds.length === 0) return [];
return db
.select()
.from(roleViewports)
.where(inArray(roleViewports.roleId, roleIds));
}
async getUserDataScope(userId: string): Promise<string> {
const db = getDb();
const [user] = await db
.select({ dataScope: users.dataScope })
.from(users)
.where(eq(users.id, userId));
return user?.dataScope ?? "self";
}
async getAllRoles(): Promise<Role[]> {
const db = getDb();
return db.select().from(roles);
}
async getAllPermissions(): Promise<Permission[]> {
const db = getDb();
return db.select().from(permissions);
}
async assignRole(userId: string, roleId: string): Promise<void> {
const db = getDb();
await db.insert(userRoles).values({ userId, roleId });

View File

@@ -1,48 +1,81 @@
import { mysqlTable, varchar, char, timestamp } from 'drizzle-orm/mysql-core';
import {
mysqlTable,
varchar,
char,
timestamp,
text,
mysqlEnum,
} from "drizzle-orm/mysql-core";
export const users = mysqlTable('iam_users', {
id: char('id', { length: 36 }).notNull().primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
name: varchar('name', { length: 100 }).notNull(),
status: varchar('status', { length: 20 }).notNull().default('active'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
export const users = mysqlTable("iam_users", {
id: char("id", { length: 36 }).notNull().primaryKey(),
email: varchar("email", { length: 255 }).notNull().unique(),
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
name: varchar("name", { length: 100 }).notNull(),
status: varchar("status", { length: 20 }).notNull().default("active"),
dataScope: mysqlEnum("data_scope", [
"self",
"class",
"grade",
"school",
"district",
"all",
])
.notNull()
.default("self"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
});
export const roles = mysqlTable('iam_roles', {
id: char('id', { length: 36 }).notNull().primaryKey(),
name: varchar('name', { length: 50 }).notNull().unique(),
description: varchar('description', { length: 255 }),
export const roles = mysqlTable("iam_roles", {
id: char("id", { length: 36 }).notNull().primaryKey(),
name: varchar("name", { length: 50 }).notNull().unique(),
description: varchar("description", { length: 255 }),
});
export const userRoles = mysqlTable('iam_user_roles', {
userId: char('user_id', { length: 36 }).notNull(),
roleId: char('role_id', { length: 36 }).notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
export const userRoles = mysqlTable("iam_user_roles", {
userId: char("user_id", { length: 36 }).notNull(),
roleId: char("role_id", { length: 36 }).notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const permissions = mysqlTable('iam_permissions', {
id: char('id', { length: 36 }).notNull().primaryKey(),
name: varchar('name', { length: 100 }).notNull().unique(),
resource: varchar('resource', { length: 50 }).notNull(),
action: varchar('action', { length: 50 }).notNull(),
export const permissions = mysqlTable("iam_permissions", {
id: char("id", { length: 36 }).notNull().primaryKey(),
name: varchar("name", { length: 100 }).notNull().unique(),
resource: varchar("resource", { length: 50 }).notNull(),
action: varchar("action", { length: 50 }).notNull(),
});
export const rolePermissions = mysqlTable('iam_role_permissions', {
roleId: char('role_id', { length: 36 }).notNull(),
permissionId: char('permission_id', { length: 36 }).notNull(),
export const rolePermissions = mysqlTable("iam_role_permissions", {
roleId: char("role_id", { length: 36 }).notNull(),
permissionId: char("permission_id", { length: 36 }).notNull(),
});
export const refreshTokens = mysqlTable('iam_refresh_tokens', {
id: char('id', { length: 36 }).notNull().primaryKey(),
userId: char('user_id', { length: 36 }).notNull(),
tokenHash: varchar('token_hash', { length: 255 }).notNull(),
expiresAt: timestamp('expires_at').notNull(),
revokedAt: timestamp('revoked_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
export const refreshTokens = mysqlTable("iam_refresh_tokens", {
id: char("id", { length: 36 }).notNull().primaryKey(),
userId: char("user_id", { length: 36 }).notNull(),
tokenHash: varchar("token_hash", { length: 255 }).notNull(),
expiresAt: timestamp("expires_at").notNull(),
revokedAt: timestamp("revoked_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
// 视口配置表4 层模型L1 导航 / L2 路由 / L3 组件 / L4 数据)
// 每条记录代表一个角色可见的导航项
export const roleViewports = mysqlTable("iam_role_viewports", {
id: char("id", { length: 36 }).notNull().primaryKey(),
roleId: char("role_id", { length: 36 }).notNull(),
viewportKey: varchar("viewport_key", { length: 50 }).notNull(),
label: varchar("label", { length: 100 }).notNull(),
route: varchar("route", { length: 200 }).notNull(),
icon: varchar("icon", { length: 50 }),
sortOrder: varchar("sort_order", { length: 10 }).notNull().default("0"),
requiredPermission: varchar("required_permission", { length: 100 }),
// L3 组件级配置JSON控制组件内按钮/操作的显隐
componentConfig: text("component_config"),
});
export type User = typeof users.$inferSelect;
export type Role = typeof roles.$inferSelect;
export type Permission = typeof permissions.$inferSelect;
export type RoleViewport = typeof roleViewports.$inferSelect;

View File

@@ -10,7 +10,7 @@ import {
} from "../shared/errors/application-error.js";
import { env } from "../config/env.js";
import type { RegisterDto, LoginDto } from "./iam.dto.js";
import type { User } from "./iam.schema.js";
import type { User, Role, Permission } from "./iam.schema.js";
export interface TokenPair {
accessToken: string;
@@ -24,8 +24,21 @@ export interface UserInfo {
name: string;
roles: string[];
permissions: string[];
dataScope: string;
}
export interface ViewportItem {
key: string;
label: string;
route: string;
icon: string | null;
sortOrder: string;
requiredPermission: string | null;
}
// teacher 角色固定 ID种子数据
const TEACHER_ROLE_ID = "00000000-0000-0000-0000-000000000001";
export class IamService {
constructor(
@Inject(IamRepository) private readonly repository: IamRepository,
@@ -48,8 +61,8 @@ export class IamService {
name: dto.name,
});
// P2 骨架:默认分配 teacher 角色(实际应通过邀请码注册)
// await this.repository.assignRole(userId, defaultRoleId);
// 默认分配 teacher 角色
await this.repository.assignRole(userId, TEACHER_ROLE_ID);
const { tokens } = await this.issueTokens(user);
const info = await this.buildUserInfo(user);
@@ -104,12 +117,55 @@ export class IamService {
return this.buildUserInfo(user);
}
// 有效权限聚合:多角色权限去重
async getEffectivePermissions(userId: string): Promise<string[]> {
const perms = await this.repository.getUserPermissions(userId);
const unique = new Set(perms.map((p) => p.name));
return [...unique];
}
async getUserViewports(userId: string): Promise<ViewportItem[]> {
const viewports = await this.repository.getUserViewports(userId);
const permissions = await this.getEffectivePermissions(userId);
// 过滤:如果视口需要权限且用户不具备,则不返回
return viewports
.filter((vp) => {
if (!vp.requiredPermission) return true;
return permissions.includes(vp.requiredPermission);
})
.map((vp) => ({
key: vp.viewportKey,
label: vp.label,
route: vp.route,
icon: vp.icon,
sortOrder: vp.sortOrder,
requiredPermission: vp.requiredPermission,
}))
.sort((a, b) => a.sortOrder.localeCompare(b.sortOrder));
}
async getAllRoles(): Promise<Role[]> {
return this.repository.getAllRoles();
}
async getAllPermissions(): Promise<Permission[]> {
return this.repository.getAllPermissions();
}
private async issueTokens(user: User): Promise<{ tokens: TokenPair }> {
const roles = await this.repository.getUserRoles(user.id);
const roleNames = roles.map((r) => r.name);
const dataScope = user.dataScope;
const accessToken = jwt.sign(
{ sub: user.id, email: user.email, roles: roleNames, type: "access" },
{
sub: user.id,
email: user.email,
roles: roleNames,
dataScope,
type: "access",
},
env.JWT_SECRET,
{ issuer: env.JWT_ISSUER, audience: env.JWT_AUDIENCE, expiresIn: "15m" },
);
@@ -147,6 +203,7 @@ export class IamService {
name: user.name,
roles: roles.map((r) => r.name),
permissions: permissions.map((p) => p.name),
dataScope: user.dataScope,
};
}
}

View File

@@ -0,0 +1,46 @@
import { Controller, Get, Req } from "@nestjs/common";
import type { Request } from "express";
import { IamService } from "./iam.service.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
// RBAC 管理端点:角色/权限/视口查询
@Controller("iam")
export class RbacController {
constructor(private readonly service: IamService) {}
// 获取当前用户的视口配置L1 导航)
@Get("viewports")
async viewports(@Req() req: Request) {
const userId = req.headers["x-user-id"] as string;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const data = await this.service.getUserViewports(userId);
return { success: true as const, data };
}
// 获取当前用户的有效权限
@Get("permissions/effective")
async effectivePermissions(@Req() req: Request) {
const userId = req.headers["x-user-id"] as string;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const permissions = await this.service.getEffectivePermissions(userId);
return { success: true as const, data: { permissions } };
}
// 列出所有角色(管理端用)
@Get("roles")
async roles() {
const data = await this.service.getAllRoles();
return { success: true as const, data };
}
// 列出所有权限点(管理端用)
@Get("permissions")
async permissions() {
const data = await this.service.getAllPermissions();
return { success: true as const, data };
}
}