diff --git a/scripts/iam-init.sql b/scripts/iam-init.sql index 2cb30b5..eb61ba4 100644 --- a/scripts/iam-init.sql +++ b/scripts/iam-init.sql @@ -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); diff --git a/services/iam/src/iam/iam.module.ts b/services/iam/src/iam/iam.module.ts index af2802e..843c5a3 100644 --- a/services/iam/src/iam/iam.module.ts +++ b/services/iam/src/iam/iam.module.ts @@ -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 {} diff --git a/services/iam/src/iam/iam.repository.ts b/services/iam/src/iam/iam.repository.ts index 111f84b..9579e41 100644 --- a/services/iam/src/iam/iam.repository.ts +++ b/services/iam/src/iam/iam.repository.ts @@ -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 { + 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 { + 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 { + const db = getDb(); + return db.select().from(roles); + } + + async getAllPermissions(): Promise { + const db = getDb(); + return db.select().from(permissions); + } + async assignRole(userId: string, roleId: string): Promise { const db = getDb(); await db.insert(userRoles).values({ userId, roleId }); diff --git a/services/iam/src/iam/iam.schema.ts b/services/iam/src/iam/iam.schema.ts index a0b6867..c2603f3 100644 --- a/services/iam/src/iam/iam.schema.ts +++ b/services/iam/src/iam/iam.schema.ts @@ -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; diff --git a/services/iam/src/iam/iam.service.ts b/services/iam/src/iam/iam.service.ts index 71c6d52..4f1ff85 100644 --- a/services/iam/src/iam/iam.service.ts +++ b/services/iam/src/iam/iam.service.ts @@ -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 { + const perms = await this.repository.getUserPermissions(userId); + const unique = new Set(perms.map((p) => p.name)); + return [...unique]; + } + + async getUserViewports(userId: string): Promise { + 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 { + return this.repository.getAllRoles(); + } + + async getAllPermissions(): Promise { + 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, }; } } diff --git a/services/iam/src/iam/rbac.controller.ts b/services/iam/src/iam/rbac.controller.ts new file mode 100644 index 0000000..1f20ed7 --- /dev/null +++ b/services/iam/src/iam/rbac.controller.ts @@ -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 }; + } +}