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:
@@ -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);
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
46
services/iam/src/iam/rbac.controller.ts
Normal file
46
services/iam/src/iam/rbac.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user