diff --git a/src/app.module.ts b/src/app.module.ts index 45bb020..90ec5c9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -48,6 +48,13 @@ import { NexusToken } from './modules/nexus/entities/nexus-token.entity'; import { NexusBuild } from './modules/nexus/entities/nexus-build.entity'; import { UserGroupModule } from './modules/user-group/user-group.module'; import { UserGroup } from './modules/user-group/entities/user-group.entity'; +import { RbacModule } from './modules/rbac/rbac.module'; +import { Role } from './modules/rbac/entities/role.entity'; +import { RolePermission } from './modules/rbac/entities/role-permission.entity'; +import { UserRoleAssignment } from './modules/rbac/entities/user-role-assignment.entity'; +import { UserRoleAssignmentDeviceGroup } from './modules/rbac/entities/user-role-assignment-device-group.entity'; +import { ConsoleAudit } from './modules/rbac/entities/console-audit.entity'; +import { RbacGuard } from './modules/rbac/guards/rbac.guard'; /** * 应用根模块 @@ -112,6 +119,11 @@ import { UserGroup } from './modules/user-group/entities/user-group.entity'; NexusToken, NexusBuild, UserGroup, + Role, + RolePermission, + UserRoleAssignment, + UserRoleAssignmentDeviceGroup, + ConsoleAudit, ], synchronize: true, logging: false, @@ -132,6 +144,7 @@ import { UserGroup } from './modules/user-group/entities/user-group.entity'; UpdateCheckModule, NexusModule, UserGroupModule, + RbacModule, ], providers: [ { @@ -142,6 +155,10 @@ import { UserGroup } from './modules/user-group/entities/user-group.entity'; provide: APP_GUARD, useClass: JwtAuthGuard, }, + { + provide: APP_GUARD, + useClass: RbacGuard, + }, ], }) export class AppModule {} diff --git a/src/common/guards/admin.guard.ts b/src/common/guards/admin.guard.ts index c9b0f54..253a86e 100644 --- a/src/common/guards/admin.guard.ts +++ b/src/common/guards/admin.guard.ts @@ -4,6 +4,8 @@ import { ExecutionContext, ForbiddenException, } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { User, UserStatus } from '../../modules/user/entities/user.entity'; @Injectable() /** @@ -14,20 +16,33 @@ import { * 只有管理员才能访问的路由会使用此守卫 * * 验证逻辑: - * 检查用户信息中的isAdmin字段 + * 读取数据库中的当前用户状态和 isAdmin 字段,不信任 JWT 内的旧权限状态 */ export class AdminGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean { + constructor(private readonly dataSource: DataSource) {} + + async canActivate(context: ExecutionContext): Promise { const request = context .switchToHttp() - .getRequest<{ user?: { isAdmin?: boolean } }>(); + .getRequest<{ user?: { id?: string } }>(); const user = request.user; if (!user) { throw new ForbiddenException('请先登录'); } - if (!user.isAdmin) { + if (!user.id) { + throw new ForbiddenException('授权服务不可用'); + } + + const currentUser = await this.dataSource.getRepository(User).findOne({ + where: { guid: user.id }, + select: ['guid', 'isAdmin', 'status'], + }); + const isAdmin = + currentUser?.isAdmin === true && currentUser.status === UserStatus.ACTIVE; + + if (!isAdmin) { throw new ForbiddenException('无权限访问,需要管理员权限'); } diff --git a/src/modules/address-book/address-book.controller.ts b/src/modules/address-book/address-book.controller.ts index 35cc3d5..f27a414 100644 --- a/src/modules/address-book/address-book.controller.ts +++ b/src/modules/address-book/address-book.controller.ts @@ -10,7 +10,6 @@ import { Query, HttpCode, HttpStatus, - UseGuards, } from '@nestjs/common'; import { AddressBookService } from './services'; import { @@ -31,7 +30,7 @@ import { } from './dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { AddressBookRuleService } from './services/address-book-rule.service'; -import { AdminGuard } from '../../common/guards/admin.guard'; +import { RequirePermission } from '../rbac/decorators/require-permission.decorator'; /** * 地址簿控制器 @@ -240,6 +239,7 @@ export class AddressBookController { } @Get('shared/list') + @RequirePermission('address_books.view') @HttpCode(HttpStatus.OK) getWebSharedAddressBooks( @Query() query: PaginationDto, @@ -260,7 +260,7 @@ export class AddressBookController { * @returns 操作结果 */ @Post('shared/add') - @UseGuards(AdminGuard) + @RequirePermission('address_books.share') @HttpCode(HttpStatus.OK) async addSharedAddressBook( @Body() dto: CreateAddressBookProfileDto, @@ -288,7 +288,7 @@ export class AddressBookController { * @returns 操作结果 */ @Put('shared/update/profile') - @UseGuards(AdminGuard) + @RequirePermission('address_books.edit') @HttpCode(HttpStatus.OK) async updateSharedAddressBook( @Body() dto: UpdateAddressBookProfileDto, @@ -318,7 +318,7 @@ export class AddressBookController { * @returns 操作结果 */ @Delete('shared') - @UseGuards(AdminGuard) + @RequirePermission('address_books.edit') @HttpCode(HttpStatus.OK) async deleteSharedAddressBooks( @Body() guids: string[], @@ -573,6 +573,7 @@ export class AddressBookController { * @returns 规则列表(分页) */ @Get('rules') + @RequirePermission('address_books.view') @HttpCode(HttpStatus.OK) async getRules( @Query() query: RuleQueryDto, @@ -590,7 +591,7 @@ export class AddressBookController { * @returns 新创建的规则 GUID */ @Post('rule') - @UseGuards(AdminGuard) + @RequirePermission('address_books.share') @HttpCode(HttpStatus.OK) async addRule(@Body() dto: CreateRuleDto, @CurrentUser('id') userId: number) { return this.ruleService.createRule(dto, String(userId)); @@ -605,7 +606,7 @@ export class AddressBookController { * @returns 更新成功消息 */ @Patch('rule') - @UseGuards(AdminGuard) + @RequirePermission('address_books.share') @HttpCode(HttpStatus.OK) async updateRule( @Body() dto: UpdateRuleDto, @@ -623,7 +624,7 @@ export class AddressBookController { * @returns 删除成功消息 */ @Delete('rules') - @UseGuards(AdminGuard) + @RequirePermission('address_books.share') @HttpCode(HttpStatus.OK) async deleteRules( @Body() ruleGuids: string[], diff --git a/src/modules/address-book/services/address-book-legacy.service.spec.ts b/src/modules/address-book/services/address-book-legacy.service.spec.ts new file mode 100644 index 0000000..a93764b --- /dev/null +++ b/src/modules/address-book/services/address-book-legacy.service.spec.ts @@ -0,0 +1,73 @@ +import 'reflect-metadata'; +import { Repository } from 'typeorm'; +import { AddressBookLegacyService } from './address-book-legacy.service'; +import { + AddressBook, + AddressBookPeer, + AddressBookPeerTag, + AddressBookTag, +} from '../entities'; +import { Sysinfo, Peer } from '../../../common/entities'; + +jest.mock('uuid', () => { + const cryptoModule = + jest.requireActual('node:crypto'); + return { v4: cryptoModule.randomUUID }; +}); + +type MockRepository = { + findOne: jest.Mock; + find: jest.Mock; + delete: jest.Mock; +}; + +const repository = (): MockRepository => ({ + findOne: jest.fn(), + find: jest.fn(), + delete: jest.fn().mockResolvedValue({ affected: 0 }), +}); + +describe('AddressBookLegacyService', () => { + it("does not delete peer tags belonging to another user's address book", async () => { + const addressBookRepository = repository(); + const addressBookPeerRepository = repository(); + const addressBookTagRepository = repository(); + const addressBookPeerTagRepository = repository(); + const sysinfoRepository = repository(); + const peerRepository = repository(); + + addressBookRepository.findOne.mockResolvedValue({ + guid: 'book-a', + owner: 'user-a', + isPersonal: true, + }); + addressBookPeerRepository.find.mockResolvedValue([{ guid: 'entry-a' }]); + + const service = new AddressBookLegacyService( + addressBookRepository as unknown as Repository, + addressBookPeerRepository as unknown as Repository, + addressBookTagRepository as unknown as Repository, + addressBookPeerTagRepository as unknown as Repository, + sysinfoRepository as unknown as Repository, + peerRepository as unknown as Repository, + ); + + await service.updateLegacyAddressBook( + 'user-a', + JSON.stringify({ tags: [], peers: [] }), + ); + + expect(addressBookPeerTagRepository.delete).toHaveBeenCalledTimes(1); + const criteria = addressBookPeerTagRepository.delete.mock.calls[0][0] as { + peerGuid: { value: string[] }; + }; + expect(criteria.peerGuid.value).toEqual(['entry-a']); + expect(addressBookPeerTagRepository.delete).not.toHaveBeenCalledWith({}); + expect(addressBookTagRepository.delete).toHaveBeenCalledWith({ + addressBookGuid: 'book-a', + }); + expect(addressBookPeerRepository.delete).toHaveBeenCalledWith({ + addressBookGuid: 'book-a', + }); + }); +}); diff --git a/src/modules/address-book/services/address-book-legacy.service.ts b/src/modules/address-book/services/address-book-legacy.service.ts index e7c9bef..58559ed 100644 --- a/src/modules/address-book/services/address-book-legacy.service.ts +++ b/src/modules/address-book/services/address-book-legacy.service.ts @@ -213,8 +213,17 @@ export class AddressBookLegacyService { } } - // 删除所有现有标签和设备 - await this.addressBookPeerTagRepository.delete({}); + // Remove only this address book's peer-tag links. The legacy endpoint is + // user-scoped; an empty delete criteria would erase every user's tags. + const existingPeers = await this.addressBookPeerRepository.find({ + where: { addressBookGuid }, + select: ['guid'], + }); + if (existingPeers.length > 0) { + await this.addressBookPeerTagRepository.delete({ + peerGuid: In(existingPeers.map((peer) => peer.guid)), + }); + } await this.addressBookTagRepository.delete({ addressBookGuid }); await this.addressBookPeerRepository.delete({ addressBookGuid }); diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts index e25cd77..eee1254 100644 --- a/src/modules/audit/audit.controller.ts +++ b/src/modules/audit/audit.controller.ts @@ -2,7 +2,6 @@ import { Controller, Post, Body, - UseGuards, Get, Query, Patch, @@ -18,7 +17,10 @@ import { import { FileAuditDto } from './dto/file-audit.dto'; import { AlarmAuditDto } from './dto/alarm-audit.dto'; import { Public } from '../auth/decorators/public.decorator'; -import { AdminGuard } from '../../common/guards/admin.guard'; +import { + RequirePermission, + RequireSuperAdmin, +} from '../rbac/decorators/require-permission.decorator'; /** * 审计控制器 @@ -146,7 +148,7 @@ export class AuditsController { * - 支持按连接类型过滤(type,-1表示未建立连接) * * 安全措施: - * - 使用AdminGuard进行认证 + * - 需要 audit.view 权限 * - 只有管理员可以查询审计记录 * * @param deviceId 被控端设备ID(模糊匹配) @@ -157,7 +159,7 @@ export class AuditsController { * @param current 当前页码 * @returns 连接审计列表 */ - @UseGuards(AdminGuard) + @RequirePermission('audit.view') @Get('conn') async queryConnectionAudits( @Query('deviceId') deviceId?: string, @@ -184,7 +186,7 @@ export class AuditsController { * @param id 连接审计记录主键 * @param dto 更新数据 */ - @UseGuards(AdminGuard) + @RequireSuperAdmin() @Patch('conn/:id') async updateConnectionAudit( @Param('id', ParseIntPipe) id: number, @@ -209,7 +211,7 @@ export class AuditsController { * - 支持按文件传输类型过滤(type: 0-发送, 1-接收) * * 安全措施: - * - 使用AdminGuard进行认证 + * - 需要 audit.view 权限 * - 只有管理员可以查询审计记录 * * @param deviceId 被控端设备ID(模糊匹配) @@ -220,7 +222,7 @@ export class AuditsController { * @param current 当前页码 * @returns 文件审计列表 */ - @UseGuards(AdminGuard) + @RequirePermission('audit.view') @Get('file') async queryFileAudits( @Query('deviceId') deviceId?: string, @@ -251,7 +253,7 @@ export class AuditsController { * - 支持按告警类型过滤(type: 0-IP白名单, 1-超30次尝试, 2-1分钟6次尝试, 6-IPv6前缀超限, 7-终端OS登录backoff, 8-终端OS登录并发超限) * * 安全措施: - * - 使用AdminGuard进行认证 + * - 需要 audit.view 权限 * - 只有管理员可以查询审计记录 * * @param deviceId 被控端设备ID(模糊匹配) @@ -262,7 +264,7 @@ export class AuditsController { * @param current 当前页码 * @returns 告警审计列表 */ - @UseGuards(AdminGuard) + @RequirePermission('audit.view') @Get('alarm') async queryAlarmAudits( @Query('deviceId') deviceId?: string, @@ -292,7 +294,7 @@ export class AuditsController { * - 支持按创建时间过滤 * * 安全措施: - * - 使用AdminGuard进行认证 + * - 需要 audit.view 权限 * - 只有管理员可以查询审计记录 * * @param operator 操作人(模糊匹配) @@ -301,7 +303,7 @@ export class AuditsController { * @param created_at 创建时间(UTC时间字符串) * @returns 控制台审计列表 */ - @UseGuards(AdminGuard) + @RequirePermission('audit.view') @Get('console') queryConsoleAudits( @Query('operator') operator?: string, diff --git a/src/modules/audit/audit.module.ts b/src/modules/audit/audit.module.ts index a142b27..10f95f1 100644 --- a/src/modules/audit/audit.module.ts +++ b/src/modules/audit/audit.module.ts @@ -7,6 +7,7 @@ import { ConnectionAudit } from './entities/connection-audit.entity'; import { FileAudit } from './entities/file-audit.entity'; import { AlarmAudit } from './entities/alarm-audit.entity'; import { SettingsModule } from '../settings/settings.module'; +import { RbacModule } from '../rbac/rbac.module'; /** * 审计模块 @@ -27,6 +28,7 @@ import { SettingsModule } from '../settings/settings.module'; imports: [ TypeOrmModule.forFeature([ConnectionAudit, FileAudit, AlarmAudit]), SettingsModule, + RbacModule, ], controllers: [AuditController, AuditsController], providers: [AuditService, AuditCleanupService], diff --git a/src/modules/audit/audit.service.ts b/src/modules/audit/audit.service.ts index 384790a..1a3dd34 100644 --- a/src/modules/audit/audit.service.ts +++ b/src/modules/audit/audit.service.ts @@ -8,6 +8,7 @@ import { ConnectionAuditDto } from './dto/connection-audit.dto'; import { UpdateConnectionAuditDto } from './dto/connection-audit.dto'; import { FileAuditDto } from './dto/file-audit.dto'; import { AlarmAuditDto } from './dto/alarm-audit.dto'; +import { RbacAuditService } from '../rbac/services/rbac-audit.service'; @Injectable() /** @@ -32,6 +33,7 @@ export class AuditService { private readonly fileAuditRepository: Repository, @InjectRepository(AlarmAudit) private readonly alarmAuditRepository: Repository, + private readonly rbacAuditService: RbacAuditService, ) {} /** @@ -491,16 +493,12 @@ export class AuditService { * @param filters 过滤条件 * @returns 控制台审计列表 */ - queryConsoleAudits(_filters: { + queryConsoleAudits(filters: { operator?: string; pageSize?: number; current?: number; created_at?: string; }) { - // 控制台审计暂时没有实体,返回空列表 - return { - data: [], - total: 0, - }; + return this.rbacAuditService.query(filters); } } diff --git a/src/modules/auth/services/auth-token.service.ts b/src/modules/auth/services/auth-token.service.ts index 4c5ffc5..9a35ff7 100644 --- a/src/modules/auth/services/auth-token.service.ts +++ b/src/modules/auth/services/auth-token.service.ts @@ -104,8 +104,24 @@ export class AuthTokenService { try { const payload = this.jwtService.verify(token); + // JWT types are compile-time only. Reject malformed signed payloads + // before they reach a TypeORM where clause, where undefined values may + // otherwise be ignored. + if ( + typeof payload.sub !== 'string' || + payload.sub.length === 0 || + typeof payload.jti !== 'string' || + payload.jti.length === 0 + ) { + return null; + } + const tokenRecord = await this.tokenRepository.findOne({ - where: { jti: payload.jti, isRevoked: false }, + where: { + userGuid: payload.sub, + jti: payload.jti, + isRevoked: false, + }, }); if (!tokenRecord) { diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 15011c4..972574e 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -415,6 +415,16 @@ export class AuthService { * @returns 令牌负载,验证失败返回null */ async validateToken(token: string): Promise { - return this.tokenService.validateToken(token); + const payload = await this.tokenService.validateToken(token); + if (!payload) return null; + + // A valid signature and token row do not prove that the account is still + // active. Check the current user row so legacy/client routes cannot keep + // working after an administrator disables or deletes the account. + const user = await this.userRepository.findOne({ + where: { guid: payload.sub }, + select: ['guid', 'status'], + }); + return user?.status === UserStatus.ACTIVE ? payload : null; } } diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/jwt.strategy.ts index f64e215..f37b696 100644 --- a/src/modules/auth/strategies/jwt.strategy.ts +++ b/src/modules/auth/strategies/jwt.strategy.ts @@ -86,7 +86,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { */ async validate( req: Request, - payload: JwtPayload, + _payload: JwtPayload, ): Promise> { const token = extractToken(req); @@ -101,7 +101,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { throw new UnauthorizedException('Token 已失效或被撤销'); } - const { sub, username, email, isAdmin, jti } = payload; + const { sub, username, email, isAdmin, jti } = validPayload; // 保持原有字段名 id,实际值是用户的 guid return { diff --git a/src/modules/dashboard/dashboard.controller.ts b/src/modules/dashboard/dashboard.controller.ts index ee81518..df6e863 100644 --- a/src/modules/dashboard/dashboard.controller.ts +++ b/src/modules/dashboard/dashboard.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { DashboardService } from './dashboard.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequireSuperAdmin } from '../rbac/decorators/require-permission.decorator'; import { DashboardOverviewDto, DashboardStatisticsDto, @@ -14,6 +15,7 @@ import { */ @Controller('dashboard') @UseGuards(JwtAuthGuard) +@RequireSuperAdmin() export class DashboardController { constructor(private readonly dashboardService: DashboardService) {} diff --git a/src/modules/device-group/device-group.controller.ts b/src/modules/device-group/device-group.controller.ts index 14027dc..c08e1c7 100644 --- a/src/modules/device-group/device-group.controller.ts +++ b/src/modules/device-group/device-group.controller.ts @@ -10,7 +10,6 @@ import { UseGuards, HttpCode, HttpStatus, - NotFoundException, BadRequestException, } from '@nestjs/common'; import { DeviceGroupService } from './device-group.service'; @@ -28,6 +27,8 @@ import { DisconnectStoreService } from '../heartbeat/services/disconnect-store.s import { HeartbeatService } from '../heartbeat/heartbeat.service'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { AdminGuard } from '../../common/guards/admin.guard'; +import { RequirePermission } from '../rbac/decorators/require-permission.decorator'; +import { RbacAuthorizationService } from '../rbac/services/rbac-authorization.service'; /** * 设备组控制器 @@ -45,6 +46,7 @@ export class DeviceGroupController { private readonly peerService: PeerService, private readonly disconnectStoreService: DisconnectStoreService, private readonly heartbeatService: HeartbeatService, + private readonly rbacAuthorizationService: RbacAuthorizationService, ) {} // ============ 客户端 API 接口 ============ @@ -67,13 +69,14 @@ export class DeviceGroupController { @Get('device-group/accessible') async getAccessibleDeviceGroups( @CurrentUser('id') userId: string, - @CurrentUser('isAdmin') isAdmin: boolean, @Query() query: DeviceGroupQueryDto, ) { + const currentUser = + await this.rbacAuthorizationService.getCurrentUser(userId); return this.deviceGroupService.getAccessibleDeviceGroups( userId, query, - isAdmin, + currentUser.isAdmin, ); } @@ -100,10 +103,15 @@ export class DeviceGroupController { @Get('peers') async getAccessiblePeers( @CurrentUser('id') userId: string, - @CurrentUser('isAdmin') isAdmin: boolean, @Query() query: PeerQueryDto, ) { - return this.peerService.getAccessiblePeers(userId, query, isAdmin); + const currentUser = + await this.rbacAuthorizationService.getCurrentUser(userId); + return this.peerService.getAccessiblePeers( + userId, + query, + currentUser.isAdmin, + ); } // ============ 管理员 API 接口 ============ @@ -113,21 +121,24 @@ export class DeviceGroupController { * 管理员可以查看所有设备组 * * @param userId 当前用户ID(从JWT令牌中提取) - * @param isAdmin 是否为管理员(从JWT令牌中提取) * @param query 查询参数(分页、名称过滤) * @returns 设备组列表(分页) */ @Get('device-groups') - @UseGuards(AdminGuard) + @RequirePermission('strategies.assign') async getDeviceGroups( @CurrentUser('id') userId: string, - @CurrentUser('isAdmin') isAdmin: boolean, @Query() query: DeviceGroupQueryDto, ) { + const scope = await this.rbacAuthorizationService.getPermissionScope( + userId, + 'strategies.assign', + ); return this.deviceGroupService.getAccessibleDeviceGroups( userId, query, - isAdmin, + scope.global, + scope, ); } @@ -143,11 +154,13 @@ export class DeviceGroupController { @HttpCode(HttpStatus.OK) async createDeviceGroup( @Body() body: { name: string; note?: string; allowed_incomings?: any[] }, + @CurrentUser('id') userId: string, ) { return this.deviceGroupService.createDeviceGroup( body.name, body.note, body.allowed_incomings, + userId, ); } @@ -170,12 +183,14 @@ export class DeviceGroupController { note?: string; allowed_incomings?: any[]; }, + @CurrentUser('id') userId: string, ) { return this.deviceGroupService.updateDeviceGroup( guid, body.name, body.note, body.allowed_incomings, + userId, ); } @@ -189,8 +204,11 @@ export class DeviceGroupController { @Delete('device-groups/:guid') @UseGuards(AdminGuard) @HttpCode(HttpStatus.OK) - async deleteDeviceGroup(@Param('guid') guid: string) { - await this.deviceGroupService.deleteDeviceGroup(guid); + async deleteDeviceGroup( + @Param('guid') guid: string, + @CurrentUser('id') userId: string, + ) { + await this.deviceGroupService.deleteDeviceGroup(guid, userId); return { message: '设备组删除成功' }; } @@ -205,8 +223,12 @@ export class DeviceGroupController { @Post('device-groups/:guid') @UseGuards(AdminGuard) @HttpCode(HttpStatus.OK) - async addDevicesToGroup(@Param('guid') guid: string, @Body() body: string[]) { - return this.deviceGroupService.addDevicesToGroup(guid, body); + async addDevicesToGroup( + @Param('guid') guid: string, + @Body() body: string[], + @CurrentUser('id') userId: string, + ) { + return this.deviceGroupService.addDevicesToGroup(guid, body, userId); } /** @@ -223,8 +245,9 @@ export class DeviceGroupController { async removeDevicesFromGroup( @Param('guid') guid: string, @Body() body: string[], + @CurrentUser('id') userId: string, ) { - return this.deviceGroupService.removeDevicesFromGroup(guid, body); + return this.deviceGroupService.removeDevicesFromGroup(guid, body, userId); } /** @@ -232,17 +255,25 @@ export class DeviceGroupController { * 管理员可以查看所有设备 * * @param userId 当前用户ID(从JWT令牌中提取) - * @param isAdmin 是否为管理员(从JWT令牌中提取) * @param query 查询参数(分页、过滤) * @returns 设备列表(分页) */ @Get('devices') + @RequirePermission('devices.view') async getDevices( @CurrentUser('id') userId: string, - @CurrentUser('isAdmin') isAdmin: boolean, @Query() query: DeviceQueryDto, ) { - return this.deviceGroupService.getDevices(userId, query, isAdmin); + const scope = await this.rbacAuthorizationService.getPermissionScope( + userId, + 'devices.view', + ); + return this.deviceGroupService.getDevices( + userId, + query, + scope.global, + scope, + ); } /** @@ -253,13 +284,15 @@ export class DeviceGroupController { * @returns 操作结果 */ @Patch('devices/status') - @UseGuards(AdminGuard) + @RequirePermission('devices.status') async updateDeviceStatus( + @CurrentUser('id') userId: string, @Body() dto: UpdateDeviceStatusDto, ): Promise<{ success: boolean; data: DeviceOperationResult }> { const result = await this.deviceGroupService.updateDeviceStatus( dto.guids, dto.status, + userId, ); return { success: result.failedCount === 0, @@ -279,12 +312,13 @@ export class DeviceGroupController { * @returns 更新结果 */ @Patch('devices/:guid') - @UseGuards(AdminGuard) + @RequirePermission('devices.edit') async updateDevice( @Param('guid') guid: string, @Body() dto: UpdateDeviceDto, + @CurrentUser('id') userId: string, ) { - await this.deviceGroupService.updateDevice(guid, dto); + await this.deviceGroupService.updateDevice(guid, dto, userId); return { message: '设备更新成功' }; } @@ -296,10 +330,13 @@ export class DeviceGroupController { * @returns 删除结果 */ @Delete('devices/:guid') - @UseGuards(AdminGuard) + @RequirePermission('devices.delete') @HttpCode(HttpStatus.OK) - async deleteDevice(@Param('guid') guid: string) { - await this.deviceGroupService.deleteDevice(guid); + async deleteDevice( + @Param('guid') guid: string, + @CurrentUser('id') userId: string, + ) { + await this.deviceGroupService.deleteDevice(guid, userId); return { message: '设备已删除' }; } @@ -313,16 +350,21 @@ export class DeviceGroupController { * @returns 操作结果 */ @Post('devices/:uuid/disconnect') - @UseGuards(AdminGuard) + @RequirePermission('devices.disconnect') @HttpCode(HttpStatus.OK) async disconnectDevice( @Param('uuid') uuid: string, @Body() dto: DisconnectDto, + @CurrentUser('id') userId: string, ) { - const peer = await this.peerService.findByUuid(uuid); - if (!peer) { - throw new NotFoundException('设备不存在'); - } + // Check the current device-group scope before inspecting connections or + // enqueueing a disconnect command. A scoped operator must not be able to + // act on an out-of-scope device by supplying its UUID directly. + await this.rbacAuthorizationService.assertDeviceAccess( + userId, + 'devices.disconnect', + uuid, + ); // 验证请求断开的连接ID是否为该设备的活跃连接 const activeConnIds = diff --git a/src/modules/device-group/device-group.module.ts b/src/modules/device-group/device-group.module.ts index 07481f5..e10e1b1 100644 --- a/src/modules/device-group/device-group.module.ts +++ b/src/modules/device-group/device-group.module.ts @@ -11,6 +11,8 @@ import { User } from '../user/entities/user.entity'; import { Strategy } from '../strategy/entities/strategy.entity'; import { AuthModule } from '../auth/auth.module'; import { HeartbeatModule } from '../heartbeat/heartbeat.module'; +import { RbacModule } from '../rbac/rbac.module'; +import { AdminGuard } from '../../common/guards/admin.guard'; /** * 设备组模块 @@ -42,9 +44,10 @@ import { HeartbeatModule } from '../heartbeat/heartbeat.module'; ]), AuthModule, HeartbeatModule, + RbacModule, ], controllers: [DeviceGroupController], - providers: [DeviceGroupService, PeerService], + providers: [DeviceGroupService, PeerService, AdminGuard], exports: [DeviceGroupService, PeerService], }) export class DeviceGroupModule {} diff --git a/src/modules/device-group/device-group.service.ts b/src/modules/device-group/device-group.service.ts index 2ea3f0b..d106d07 100644 --- a/src/modules/device-group/device-group.service.ts +++ b/src/modules/device-group/device-group.service.ts @@ -4,11 +4,12 @@ import { BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In } from 'typeorm'; +import { DataSource, Repository, In } from 'typeorm'; import * as uuid from 'uuid'; import { DeviceGroup } from './entities/device-group.entity'; import { User, UserStatus } from '../user/entities/user.entity'; import { Peer, PeerStatus } from '../../common/entities/peer.entity'; +import { Sysinfo } from '../../common/entities/sysinfo.entity'; import { Strategy } from '../strategy/entities/strategy.entity'; import { DeviceGroupUserPermission } from './entities/device-group-user-permission.entity'; import { @@ -17,6 +18,9 @@ import { DeviceOperationFailure, } from './dto/device-status.dto'; import { UpdateDeviceDto } from './dto/update-device.dto'; +import type { PermissionScope } from '../rbac/services/rbac-authorization.service'; +import { UserRoleAssignmentDeviceGroup } from '../rbac/entities/user-role-assignment-device-group.entity'; +import { RbacAuthorizationService } from '../rbac/services/rbac-authorization.service'; @Injectable() /** @@ -40,10 +44,14 @@ export class DeviceGroupService { private userRepository: Repository, @InjectRepository(Peer) private peerRepository: Repository, + @InjectRepository(Sysinfo) + private sysinfoRepository: Repository, @InjectRepository(DeviceGroupUserPermission) private deviceGroupUserPermissionRepository: Repository, @InjectRepository(Strategy) private strategyRepository: Repository, + private readonly dataSource: DataSource, + private readonly rbacAuthorizationService: RbacAuthorizationService, ) {} /** @@ -59,6 +67,7 @@ export class DeviceGroupService { userGuid: string, query: { current: number; pageSize: number; name?: string }, isAdmin: boolean = false, + rbacScope?: PermissionScope, ): Promise<{ data: { guid: string; name: string; note?: string }[]; total: number; @@ -67,7 +76,7 @@ export class DeviceGroupService { const skip = (current - 1) * pageSize; // 管理员可以看到所有设备组 - if (isAdmin) { + if (isAdmin || rbacScope) { let queryBuilder = this.deviceGroupRepository .createQueryBuilder('dg') .select(['dg.guid', 'dg.name', 'dg.note']) @@ -75,6 +84,17 @@ export class DeviceGroupService { .skip(skip) .take(pageSize); + if (rbacScope && !rbacScope.global) { + if (rbacScope.deviceGroupGuids.size === 0) { + queryBuilder = queryBuilder.andWhere('1 = 0'); + } else { + queryBuilder = queryBuilder.andWhere( + 'dg.guid IN (:...rbacDeviceGroups)', + { rbacDeviceGroups: [...rbacScope.deviceGroupGuids] }, + ); + } + } + if (name) { queryBuilder = queryBuilder.andWhere('dg.name LIKE :name', { name: `%${name}%`, @@ -261,9 +281,11 @@ export class DeviceGroupService { */ async createDeviceGroup( name: string, - note?: string, - _allowedIncomings?: unknown[], + note: string | undefined, + _allowedIncomings: unknown[] | undefined, + actorGuid: string, ) { + await this.rbacAuthorizationService.requireSuperAdmin(actorGuid); // 检查设备组名称是否已存在 const existingGroup = await this.deviceGroupRepository.findOne({ where: { name }, @@ -292,10 +314,12 @@ export class DeviceGroupService { */ async updateDeviceGroup( guid: string, - name?: string, - note?: string, - _allowedIncomings?: unknown[], + name: string | undefined, + note: string | undefined, + _allowedIncomings: unknown[] | undefined, + actorGuid: string, ) { + await this.rbacAuthorizationService.requireSuperAdmin(actorGuid); const deviceGroup = await this.deviceGroupRepository.findOne({ where: { guid }, }); @@ -327,15 +351,28 @@ export class DeviceGroupService { * 删除设备组 * @param guid 设备组GUID */ - async deleteDeviceGroup(guid: string) { - const deviceGroup = await this.deviceGroupRepository.findOne({ - where: { guid }, - }); - if (!deviceGroup) { - throw new NotFoundException('设备组不存在'); - } + async deleteDeviceGroup(guid: string, actorGuid: string) { + await this.rbacAuthorizationService.requireSuperAdmin(actorGuid); + await this.dataSource.transaction(async (manager) => { + const deviceGroupRepository = manager.getRepository(DeviceGroup); + const deviceGroup = await deviceGroupRepository.findOne({ + where: { guid }, + }); + if (!deviceGroup) { + throw new NotFoundException('设备组不存在'); + } + + const scopedAssignments = await manager + .getRepository(UserRoleAssignmentDeviceGroup) + .count({ where: { deviceGroupGuid: guid } }); + if (scopedAssignments > 0) { + throw new BadRequestException( + '设备组仍被角色授权引用,不能删除,请先移除相关授权', + ); + } - await this.deviceGroupRepository.remove(deviceGroup); + await deviceGroupRepository.remove(deviceGroup); + }); } /** @@ -343,7 +380,12 @@ export class DeviceGroupService { * @param guid 设备组GUID * @param deviceIds 设备ID列表 */ - async addDevicesToGroup(guid: string, deviceIds: string[]) { + async addDevicesToGroup( + guid: string, + deviceIds: string[], + actorGuid: string, + ) { + await this.rbacAuthorizationService.requireSuperAdmin(actorGuid); const deviceGroup = await this.deviceGroupRepository.findOne({ where: { guid }, }); @@ -376,7 +418,12 @@ export class DeviceGroupService { * @param guid 设备组GUID * @param deviceIds 设备ID列表 */ - async removeDevicesFromGroup(guid: string, deviceIds: string[]) { + async removeDevicesFromGroup( + guid: string, + deviceIds: string[], + actorGuid: string, + ) { + await this.rbacAuthorizationService.requireSuperAdmin(actorGuid); const deviceGroup = await this.deviceGroupRepository.findOne({ where: { guid }, }); @@ -424,6 +471,7 @@ export class DeviceGroupService { group_name?: string; }, isAdmin: boolean = false, + rbacScope?: PermissionScope, ): Promise<{ data: any[]; total: number }> { const { current, @@ -445,14 +493,18 @@ export class DeviceGroupService { 'peer.uuid', 'peer.userGuid', 'peer.deviceGroupGuid', + 'peer.strategyGuid', + 'peer.note', + 'peer.status', 'peer.ver', 'peer.modifiedAt', + 'peer.lastHeartbeat', 'peer.updatedAt', 'dg.name', ]); // 管理员可以看到所有设备 - if (!isAdmin) { + if (!isAdmin && !rbacScope) { // 普通用户只能看到自己有权限访问的设备 queryBuilder = queryBuilder.andWhere( `(peer.userGuid = :userGuid @@ -465,6 +517,19 @@ export class DeviceGroupService { ); } + // RBAC scope is an additional administrative boundary. It is applied + // before pagination/count and intentionally excludes ungrouped devices. + if (rbacScope && !rbacScope.global) { + if (!rbacScope.deviceGroupGuids.size) { + queryBuilder = queryBuilder.andWhere('1 = 0'); + } else { + queryBuilder = queryBuilder.andWhere( + 'peer.deviceGroupGuid IN (:...rbacDeviceGroups)', + { rbacDeviceGroups: [...rbacScope.deviceGroupGuids] }, + ); + } + } + // 按设备ID过滤 if (id) { queryBuilder = queryBuilder.andWhere('peer.id LIKE :id', { @@ -474,9 +539,13 @@ export class DeviceGroupService { // 按设备名称过滤 if (device_name) { - queryBuilder = queryBuilder.andWhere('peer.name LIKE :deviceName', { - deviceName: `%${device_name}%`, - }); + queryBuilder = queryBuilder.andWhere( + `EXISTS ( + SELECT 1 FROM sysinfos si + WHERE si.uuid = peer.uuid AND si.hostname LIKE :deviceName + )`, + { deviceName: `%${device_name}%` }, + ); } // 按用户名过滤 @@ -493,7 +562,10 @@ export class DeviceGroupService { // 按设备用户名过滤 if (device_username) { queryBuilder = queryBuilder.andWhere( - 'peer.deviceUsername LIKE :deviceUsername', + `EXISTS ( + SELECT 1 FROM sysinfos si + WHERE si.uuid = peer.uuid AND si.username LIKE :deviceUsername + )`, { deviceUsername: `%${device_username}%` }, ); } @@ -518,18 +590,84 @@ export class DeviceGroupService { .take(pageSize) .getManyAndCount(); - return { - data: peers.map((p) => ({ - guid: p.uuid, - id: p.id, - userGuid: p.userGuid, - deviceGroupGuid: p.deviceGroupGuid, - device_group_name: - (p.deviceGroup as { name?: string } | null)?.name || '', - last_online: p.updatedAt, - })), - total, + const uuids = peers.map((peer) => peer.uuid); + const userGuids = [ + ...new Set( + peers + .map((peer) => peer.userGuid) + .filter((guid): guid is string => guid !== null), + ), + ]; + const strategyGuids = [ + ...new Set( + peers + .map((peer) => peer.strategyGuid) + .filter((guid): guid is string => guid !== null), + ), + ]; + const [sysinfos, users, strategies]: [Sysinfo[], User[], Strategy[]] = + await Promise.all([ + uuids.length + ? this.sysinfoRepository.find({ where: { uuid: In(uuids) } }) + : [], + userGuids.length + ? this.userRepository.find({ where: { guid: In(userGuids) } }) + : [], + strategyGuids.length + ? this.strategyRepository.find({ + where: { guid: In(strategyGuids) }, + }) + : [], + ]); + const sysinfoByUuid = new Map(sysinfos.map((item) => [item.uuid, item])); + const userByGuid = new Map(users.map((item) => [item.guid, item])); + const strategyByGuid = new Map(strategies.map((item) => [item.guid, item])); + const onlineAfter = new Date(Date.now() - 60_000); + + const formatVersion = (version: number): string => { + if (!version) return ''; + const major = Math.floor(version / 1_000_000); + const minor = Math.floor((version % 1_000_000) / 1_000); + const patch = Math.floor((version % 1_000) / 10); + const suffix = version % 10; + return `${major}.${minor}.${patch}${suffix ? `-${suffix}` : ''}`; }; + + const data = peers.map((peer) => { + const sysinfo = sysinfoByUuid.get(peer.uuid); + return { + guid: peer.uuid, + id: peer.id, + userGuid: peer.userGuid, + user: peer.userGuid || '', + user_name: peer.userGuid + ? userByGuid.get(peer.userGuid)?.username || '' + : '', + deviceGroupGuid: peer.deviceGroupGuid, + device_group_name: + (peer.deviceGroup as { name?: string } | null)?.name || '', + strategy_name: peer.strategyGuid + ? strategyByGuid.get(peer.strategyGuid)?.name || '' + : '', + note: peer.note || '', + status: peer.status, + is_online: peer.lastHeartbeat + ? peer.lastHeartbeat > onlineAfter + : false, + last_online: peer.lastHeartbeat?.toISOString() || null, + info: { + device_name: sysinfo?.hostname || '', + username: sysinfo?.username || '', + os: sysinfo?.os || '', + version: formatVersion(peer.ver), + cpu: sysinfo?.cpu || '', + memory: sysinfo?.memory || '', + ip: '', + }, + }; + }); + + return { data, total }; } /** @@ -542,7 +680,19 @@ export class DeviceGroupService { * @param guid 设备GUID * @param dto 更新数据 */ - async updateDevice(guid: string, dto: UpdateDeviceDto) { + async updateDevice(guid: string, dto: UpdateDeviceDto, actorGuid: string) { + await this.rbacAuthorizationService.assertDeviceAccess( + actorGuid, + 'devices.edit', + guid, + ); + if ( + dto.userName !== undefined || + dto.deviceGroupName !== undefined || + dto.strategyName !== undefined + ) { + await this.rbacAuthorizationService.requireSuperAdmin(actorGuid); + } const peer = await this.peerRepository.findOne({ where: { uuid: guid }, }); @@ -614,7 +764,13 @@ export class DeviceGroupService { async updateDeviceStatus( guids: string[], status: DeviceStatus, + actorGuid: string, ): Promise { + await this.rbacAuthorizationService.assertDevicesAccess( + actorGuid, + 'devices.status', + guids, + ); const uniqueGuids = [...new Set(guids)]; const succeeded: string[] = []; const failed: DeviceOperationFailure[] = []; @@ -663,7 +819,12 @@ export class DeviceGroupService { * 删除设备 * @param guid 设备GUID */ - async deleteDevice(guid: string) { + async deleteDevice(guid: string, actorGuid: string) { + await this.rbacAuthorizationService.assertDeviceAccess( + actorGuid, + 'devices.delete', + guid, + ); const peer = await this.peerRepository.findOne({ where: { uuid: guid }, }); diff --git a/src/modules/ldap/ldap.module.ts b/src/modules/ldap/ldap.module.ts index bf6238e..0d23e7f 100644 --- a/src/modules/ldap/ldap.module.ts +++ b/src/modules/ldap/ldap.module.ts @@ -6,6 +6,7 @@ import { LdapController } from './ldap.controller'; import { LdapService } from './ldap.service'; import { LdapSettingsService } from './ldap-settings.service'; import { UserGroupModule } from '../user-group/user-group.module'; +import { AdminGuard } from '../../common/guards/admin.guard'; /** * LDAP 认证模块 @@ -21,7 +22,7 @@ import { UserGroupModule } from '../user-group/user-group.module'; @Module({ imports: [TypeOrmModule.forFeature([SystemSetting, User]), UserGroupModule], controllers: [LdapController], - providers: [LdapService, LdapSettingsService], + providers: [LdapService, LdapSettingsService, AdminGuard], exports: [LdapService, LdapSettingsService], }) export class LdapModule {} diff --git a/src/modules/nexus/nexus.controller.ts b/src/modules/nexus/nexus.controller.ts index dd3a166..2fe39c6 100644 --- a/src/modules/nexus/nexus.controller.ts +++ b/src/modules/nexus/nexus.controller.ts @@ -18,8 +18,10 @@ import { NexusService } from './nexus.service'; import { NexusLoginDto } from './dto/nexus-auth.dto'; import { NexusGenerateDto } from './dto/nexus-client.dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireSuperAdmin } from '../rbac/decorators/require-permission.decorator'; @Controller('nexus') +@RequireSuperAdmin() export class NexusController { constructor(private readonly nexusService: NexusService) {} diff --git a/src/modules/oidc/oidc.module.ts b/src/modules/oidc/oidc.module.ts index 51a2c32..2203391 100644 --- a/src/modules/oidc/oidc.module.ts +++ b/src/modules/oidc/oidc.module.ts @@ -11,6 +11,7 @@ import { User } from '../user/entities/user.entity'; import { AuthModule } from '../auth/auth.module'; import { UserGroupModule } from '../user-group/user-group.module'; import { SettingsModule } from '../settings/settings.module'; +import { AdminGuard } from '../../common/guards/admin.guard'; @Module({ imports: [ @@ -20,7 +21,12 @@ import { SettingsModule } from '../settings/settings.module'; SettingsModule, ], controllers: [OidcController, OidcAdminController], - providers: [OidcService, OidcAdminService, OidcAuthStateCleanupService], + providers: [ + OidcService, + OidcAdminService, + OidcAuthStateCleanupService, + AdminGuard, + ], exports: [OidcService], }) export class OidcModule {} diff --git a/src/modules/rbac/constants/permission-catalog.ts b/src/modules/rbac/constants/permission-catalog.ts new file mode 100644 index 0000000..47dcba3 --- /dev/null +++ b/src/modules/rbac/constants/permission-catalog.ts @@ -0,0 +1,163 @@ +/** + * Backend-owned permission catalog. Permission identifiers are intentionally + * fixed: roles may compose these values, but callers cannot create new ones. + */ +export type PermissionCode = + | 'users.view' + | 'users.create' + | 'users.edit' + | 'users.status' + | 'users.delete' + | 'users.security' + | 'users.force_logout' + | 'user_groups.view' + | 'user_groups.create' + | 'user_groups.edit' + | 'user_groups.delete' + | 'user_groups.membership' + | 'devices.view' + | 'devices.edit' + | 'devices.status' + | 'devices.delete' + | 'devices.disconnect' + | 'address_books.view' + | 'address_books.edit' + | 'address_books.share' + | 'strategies.view' + | 'strategies.create' + | 'strategies.edit' + | 'strategies.delete' + | 'strategies.assign' + | 'audit.view'; + +export interface PermissionDefinition { + code: PermissionCode; + resource: string; + action: string; + name: string; + description: string; + scope: 'global' | 'device_group'; +} + +const definition = ( + code: PermissionCode, + resource: string, + action: string, + name: string, + scope: PermissionDefinition['scope'] = 'global', +): PermissionDefinition => ({ + code, + resource, + action, + name, + description: `${name} (${code})`, + scope, +}); + +export const PERMISSION_CATALOG: readonly PermissionDefinition[] = [ + definition('users.view', 'users', 'view', 'View users'), + definition('users.create', 'users', 'create', 'Create users'), + definition('users.edit', 'users', 'edit', 'Edit users'), + definition('users.status', 'users', 'status', 'Change user status'), + definition('users.delete', 'users', 'delete', 'Delete users'), + definition('users.security', 'users', 'security', 'Manage user security'), + definition( + 'users.force_logout', + 'users', + 'force_logout', + 'Force user logout', + ), + definition('user_groups.view', 'user_groups', 'view', 'View user groups'), + definition( + 'user_groups.create', + 'user_groups', + 'create', + 'Create user groups', + ), + definition('user_groups.edit', 'user_groups', 'edit', 'Edit user groups'), + definition( + 'user_groups.delete', + 'user_groups', + 'delete', + 'Delete user groups', + ), + definition( + 'user_groups.membership', + 'user_groups', + 'membership', + 'Manage user group membership', + ), + definition('devices.view', 'devices', 'view', 'View devices', 'device_group'), + definition( + 'devices.edit', + 'devices', + 'edit', + 'Edit device metadata', + 'device_group', + ), + definition( + 'devices.status', + 'devices', + 'status', + 'Change device status', + 'device_group', + ), + definition( + 'devices.delete', + 'devices', + 'delete', + 'Delete devices', + 'device_group', + ), + definition( + 'devices.disconnect', + 'devices', + 'disconnect', + 'Disconnect devices', + 'device_group', + ), + definition( + 'address_books.view', + 'address_books', + 'view', + 'View address books', + ), + definition( + 'address_books.edit', + 'address_books', + 'edit', + 'Edit address books', + ), + definition( + 'address_books.share', + 'address_books', + 'share', + 'Share address books', + ), + definition('strategies.view', 'strategies', 'view', 'View strategies'), + definition('strategies.create', 'strategies', 'create', 'Create strategies'), + definition('strategies.edit', 'strategies', 'edit', 'Edit strategies'), + definition('strategies.delete', 'strategies', 'delete', 'Delete strategies'), + definition( + 'strategies.assign', + 'strategies', + 'assign', + 'Assign strategies', + 'device_group', + ), + definition('audit.view', 'audit', 'view', 'View audit data'), +]; + +export const PERMISSION_CODES = PERMISSION_CATALOG.map((item) => item.code); + +export const DEVICE_SCOPED_PERMISSION_CODES = new Set([ + 'devices.view', + 'devices.edit', + 'devices.status', + 'devices.delete', + 'devices.disconnect', + 'strategies.assign', +]); + +export const isKnownPermissionCode = (code: string): code is PermissionCode => + PERMISSION_CODES.includes(code as PermissionCode); diff --git a/src/modules/rbac/decorators/require-permission.decorator.ts b/src/modules/rbac/decorators/require-permission.decorator.ts new file mode 100644 index 0000000..d083914 --- /dev/null +++ b/src/modules/rbac/decorators/require-permission.decorator.ts @@ -0,0 +1,11 @@ +import { SetMetadata } from '@nestjs/common'; +import { PermissionCode } from '../constants/permission-catalog'; + +export const REQUIRE_PERMISSION_KEY = 'rbac:required_permissions'; +export const REQUIRE_SUPER_ADMIN_KEY = 'rbac:super_admin'; + +export const RequirePermission = (...permissions: PermissionCode[]) => + SetMetadata(REQUIRE_PERMISSION_KEY, permissions); + +export const RequireSuperAdmin = () => + SetMetadata(REQUIRE_SUPER_ADMIN_KEY, true); diff --git a/src/modules/rbac/dto/role.dto.ts b/src/modules/rbac/dto/role.dto.ts new file mode 100644 index 0000000..fd33272 --- /dev/null +++ b/src/modules/rbac/dto/role.dto.ts @@ -0,0 +1,78 @@ +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; + +export class RoleQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + current = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize = 20; + + @IsOptional() + @IsString() + @MaxLength(255) + search?: string; +} + +export class CreateRoleDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + name: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; + + @IsArray() + @ArrayMaxSize(100) + @IsString({ each: true }) + @MaxLength(100, { each: true }) + permissions: string[]; +} + +export class UpdateRoleDto { + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(255) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(100) + @IsString({ each: true }) + @MaxLength(100, { each: true }) + permissions?: string[]; +} + +export class ReplaceRolePermissionsDto { + @IsArray() + @ArrayMaxSize(100) + @IsString({ each: true }) + @MaxLength(100, { each: true }) + permissions: string[]; +} diff --git a/src/modules/rbac/dto/user-role.dto.ts b/src/modules/rbac/dto/user-role.dto.ts new file mode 100644 index 0000000..8e45251 --- /dev/null +++ b/src/modules/rbac/dto/user-role.dto.ts @@ -0,0 +1,33 @@ +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsIn, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; + +export class UserRoleAssignmentDto { + @IsUUID('4') + role_guid: string; + + @IsString() + @IsIn(['global', 'device_group']) + scope_type: 'global' | 'device_group'; + + @IsOptional() + @IsArray() + @ArrayMaxSize(200) + @IsUUID('4', { each: true }) + device_group_guids?: string[]; +} + +export class ReplaceUserRolesDto { + @IsArray() + @ArrayMaxSize(100) + @ValidateNested({ each: true }) + @Type(() => UserRoleAssignmentDto) + assignments: UserRoleAssignmentDto[]; +} diff --git a/src/modules/rbac/entities/console-audit.entity.ts b/src/modules/rbac/entities/console-audit.entity.ts new file mode 100644 index 0000000..90c816c --- /dev/null +++ b/src/modules/rbac/entities/console-audit.entity.ts @@ -0,0 +1,46 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, +} from 'typeorm'; + +@Entity('console_audits') +@Index(['actorUserGuid', 'createdAt']) +@Index(['targetType', 'targetGuid', 'createdAt']) +export class ConsoleAudit { + @PrimaryColumn() + guid: string; + + @Column({ type: 'varchar', nullable: true }) + @Index() + actorUserGuid: string | null; + + @Column({ type: 'varchar' }) + targetType: string; + + @Column({ type: 'varchar', nullable: true }) + targetGuid: string | null; + + @Column({ type: 'varchar' }) + action: string; + + @Column({ type: 'varchar' }) + result: 'allowed' | 'denied'; + + @Column({ type: 'text', nullable: true }) + reason: string | null; + + @Column({ type: 'text', nullable: true }) + beforeState: string | null; + + @Column({ type: 'text', nullable: true }) + afterState: string | null; + + @Column({ type: 'varchar', nullable: true }) + requestId: string | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/modules/rbac/entities/role-permission.entity.ts b/src/modules/rbac/entities/role-permission.entity.ts new file mode 100644 index 0000000..2305a95 --- /dev/null +++ b/src/modules/rbac/entities/role-permission.entity.ts @@ -0,0 +1,20 @@ +import { Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { Role } from './role.entity'; + +@Entity('role_permissions') +@Index(['roleGuid', 'permissionCode'], { unique: true }) +export class RolePermission { + @PrimaryColumn() + @Index() + roleGuid: string; + + @PrimaryColumn({ type: 'varchar' }) + @Index() + permissionCode: string; + + @ManyToOne(() => Role, (role) => role.rolePermissions, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'roleGuid', referencedColumnName: 'guid' }) + role: Role; +} diff --git a/src/modules/rbac/entities/role.entity.ts b/src/modules/rbac/entities/role.entity.ts new file mode 100644 index 0000000..29929c8 --- /dev/null +++ b/src/modules/rbac/entities/role.entity.ts @@ -0,0 +1,36 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + OneToMany, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { RolePermission } from './role-permission.entity'; +import { UserRoleAssignment } from './user-role-assignment.entity'; + +@Entity('roles') +export class Role { + @PrimaryColumn() + guid: string; + + @Column({ type: 'varchar', unique: true, collation: 'NOCASE' }) + @Index() + name: string; + + @Column({ type: 'text', nullable: true }) + note: string | null; + + @OneToMany(() => RolePermission, (permission) => permission.role) + rolePermissions: RolePermission[]; + + @OneToMany(() => UserRoleAssignment, (assignment) => assignment.role) + assignments: UserRoleAssignment[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/modules/rbac/entities/user-role-assignment-device-group.entity.ts b/src/modules/rbac/entities/user-role-assignment-device-group.entity.ts new file mode 100644 index 0000000..defc426 --- /dev/null +++ b/src/modules/rbac/entities/user-role-assignment-device-group.entity.ts @@ -0,0 +1,29 @@ +import { Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { DeviceGroup } from '../../device-group/entities/device-group.entity'; +import { UserRoleAssignment } from './user-role-assignment.entity'; + +@Entity('user_role_assignment_device_groups') +@Index(['assignmentGuid', 'deviceGroupGuid'], { unique: true }) +export class UserRoleAssignmentDeviceGroup { + @PrimaryColumn() + @Index() + assignmentGuid: string; + + @PrimaryColumn() + @Index() + deviceGroupGuid: string; + + @ManyToOne( + () => UserRoleAssignment, + (assignment) => assignment.deviceGroups, + { + onDelete: 'CASCADE', + }, + ) + @JoinColumn({ name: 'assignmentGuid', referencedColumnName: 'guid' }) + assignment: UserRoleAssignment; + + @ManyToOne(() => DeviceGroup, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'deviceGroupGuid', referencedColumnName: 'guid' }) + deviceGroup: DeviceGroup; +} diff --git a/src/modules/rbac/entities/user-role-assignment.entity.ts b/src/modules/rbac/entities/user-role-assignment.entity.ts new file mode 100644 index 0000000..a11ada7 --- /dev/null +++ b/src/modules/rbac/entities/user-role-assignment.entity.ts @@ -0,0 +1,51 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from '../../user/entities/user.entity'; +import { Role } from './role.entity'; +import { UserRoleAssignmentDeviceGroup } from './user-role-assignment-device-group.entity'; + +export type AssignmentScopeType = 'global' | 'device_group'; + +@Entity('user_role_assignments') +@Index(['userGuid', 'roleGuid'], { unique: true }) +export class UserRoleAssignment { + @PrimaryColumn() + guid: string; + + @Column({ type: 'varchar' }) + @Index() + userGuid: string; + + @Column({ type: 'varchar' }) + @Index() + roleGuid: string; + + @Column({ type: 'varchar' }) + scopeType: AssignmentScopeType; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userGuid', referencedColumnName: 'guid' }) + user: User; + + @ManyToOne(() => Role, (role) => role.assignments, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'roleGuid', referencedColumnName: 'guid' }) + role: Role; + + @OneToMany(() => UserRoleAssignmentDeviceGroup, (group) => group.assignment) + deviceGroups: UserRoleAssignmentDeviceGroup[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/modules/rbac/guards/rbac.guard.ts b/src/modules/rbac/guards/rbac.guard.ts new file mode 100644 index 0000000..552d4d6 --- /dev/null +++ b/src/modules/rbac/guards/rbac.guard.ts @@ -0,0 +1,66 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { + REQUIRE_PERMISSION_KEY, + REQUIRE_SUPER_ADMIN_KEY, +} from '../decorators/require-permission.decorator'; +import { RbacAuditService } from '../services/rbac-audit.service'; +import { RbacAuthorizationService } from '../services/rbac-authorization.service'; + +interface RequestWithUser { + user?: { id?: string }; + id?: string; +} + +@Injectable() +export class RbacGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly authorizationService: RbacAuthorizationService, + private readonly auditService: RbacAuditService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const permissions = this.reflector.getAllAndOverride( + REQUIRE_PERMISSION_KEY, + [context.getHandler(), context.getClass()], + ); + const requiresSuperAdmin = this.reflector.getAllAndOverride( + REQUIRE_SUPER_ADMIN_KEY, + [context.getHandler(), context.getClass()], + ); + if ((!permissions || permissions.length === 0) && !requiresSuperAdmin) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const userGuid = request.user?.id; + if (!userGuid) { + throw new ForbiddenException('请先登录'); + } + + try { + if (requiresSuperAdmin) { + await this.authorizationService.requireSuperAdmin(userGuid); + } + for (const permission of permissions || []) { + await this.authorizationService.requirePermission(userGuid, permission); + } + return true; + } catch (error: unknown) { + await this.auditService.recordDenied({ + actorUserGuid: userGuid, + targetType: 'route', + action: permissions?.join(',') || 'super_admin', + reason: error instanceof Error ? error.message : String(error), + requestId: request.id, + }); + throw error; + } + } +} diff --git a/src/modules/rbac/permission.controller.ts b/src/modules/rbac/permission.controller.ts new file mode 100644 index 0000000..e0d78b9 --- /dev/null +++ b/src/modules/rbac/permission.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get } from '@nestjs/common'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { PERMISSION_CATALOG } from './constants/permission-catalog'; +import { RequireSuperAdmin } from './decorators/require-permission.decorator'; +import { RbacAuthorizationService } from './services/rbac-authorization.service'; + +@Controller('permissions') +export class PermissionController { + constructor( + private readonly authorizationService: RbacAuthorizationService, + ) {} + + @Get() + @RequireSuperAdmin() + getPermissions() { + return { + data: PERMISSION_CATALOG.map((permission) => ({ + code: permission.code, + resource: permission.resource, + action: permission.action, + name: permission.name, + description: permission.description, + scope: permission.scope, + })), + }; + } + + @Get('me') + getMyPermissions(@CurrentUser('id') userGuid: string) { + return this.authorizationService.getEffectivePermissions(userGuid); + } +} diff --git a/src/modules/rbac/rbac-authorization.service.spec.ts b/src/modules/rbac/rbac-authorization.service.spec.ts new file mode 100644 index 0000000..bec6adf --- /dev/null +++ b/src/modules/rbac/rbac-authorization.service.spec.ts @@ -0,0 +1,505 @@ +import 'reflect-metadata'; +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { DataSource, Repository } from 'typeorm'; +import { Peer } from '../../common/entities/peer.entity'; +import { AuditsController } from '../audit/audit.controller'; +import { DashboardController } from '../dashboard/dashboard.controller'; +import { DeviceGroup } from '../device-group/entities/device-group.entity'; +import { DeviceGroupController } from '../device-group/device-group.controller'; +import { User, UserStatus } from '../user/entities/user.entity'; +import { PermissionController } from './permission.controller'; +import { PERMISSION_CATALOG } from './constants/permission-catalog'; +import { REQUIRE_SUPER_ADMIN_KEY } from './decorators/require-permission.decorator'; +import { ConsoleAudit } from './entities/console-audit.entity'; +import { Role } from './entities/role.entity'; +import { RolePermission } from './entities/role-permission.entity'; +import { UserRoleAssignment } from './entities/user-role-assignment.entity'; +import { UserRoleAssignmentDeviceGroup } from './entities/user-role-assignment-device-group.entity'; +import { RbacAuditService } from './services/rbac-audit.service'; +import { RbacAuthorizationService } from './services/rbac-authorization.service'; +import { RoleService } from './services/role.service'; +import { UserRoleService } from './services/user-role.service'; + +jest.mock('uuid', () => { + const cryptoModule = + jest.requireActual('node:crypto'); + return { v4: cryptoModule.randomUUID }; +}); + +type MockRepository = { + findOne: jest.Mock; + find: jest.Mock; + create: jest.Mock; + save: jest.Mock; + exist: jest.Mock; +}; + +const repository = (): MockRepository => ({ + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn((value) => value), + save: jest.fn((value) => value), + exist: jest.fn(), +}); + +describe('RbacAuthorizationService', () => { + let userRepository: MockRepository; + let rolePermissionRepository: MockRepository; + let assignmentRepository: MockRepository; + let assignmentGroupRepository: MockRepository; + let peerRepository: MockRepository; + let deviceGroupRepository: MockRepository; + let auditService: { recordDenied: jest.Mock }; + let service: RbacAuthorizationService; + + const activeUser = { + guid: 'actor', + status: UserStatus.ACTIVE, + isAdmin: false, + } as User; + + beforeEach(() => { + userRepository = repository(); + rolePermissionRepository = repository(); + assignmentRepository = repository(); + assignmentGroupRepository = repository(); + peerRepository = repository(); + deviceGroupRepository = repository(); + auditService = { recordDenied: jest.fn().mockResolvedValue(undefined) }; + userRepository.findOne.mockResolvedValue(activeUser); + assignmentRepository.find.mockResolvedValue([]); + rolePermissionRepository.find.mockResolvedValue([]); + assignmentGroupRepository.find.mockResolvedValue([]); + peerRepository.find.mockResolvedValue([]); + service = new RbacAuthorizationService( + userRepository as unknown as Repository, + rolePermissionRepository as unknown as Repository, + assignmentRepository as unknown as Repository, + assignmentGroupRepository as unknown as Repository, + peerRepository as unknown as Repository, + deviceGroupRepository as unknown as Repository, + auditService as unknown as RbacAuditService, + ); + }); + + it('rejects disabled users before reading role grants', async () => { + userRepository.findOne.mockResolvedValue({ + ...activeUser, + status: UserStatus.DISABLED, + }); + + await expect( + service.requirePermission('actor', 'devices.view'), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(assignmentRepository.find).not.toHaveBeenCalled(); + }); + + it('applies a device-group grant and never treats it as global', async () => { + assignmentRepository.find.mockResolvedValue([ + { guid: 'assignment-1', roleGuid: 'role-1', scopeType: 'device_group' }, + ]); + rolePermissionRepository.find.mockResolvedValue([ + { roleGuid: 'role-1', permissionCode: 'devices.view' }, + ]); + assignmentGroupRepository.find.mockResolvedValue([ + { assignmentGuid: 'assignment-1', deviceGroupGuid: 'group-1' }, + ]); + + await expect( + service.getPermissionScope('actor', 'devices.view'), + ).resolves.toEqual({ + global: false, + deviceGroupGuids: new Set(['group-1']), + }); + }); + + it('makes a global grant win over narrower grants for the same action', async () => { + assignmentRepository.find.mockResolvedValue([ + { guid: 'assignment-1', roleGuid: 'role-1', scopeType: 'device_group' }, + { guid: 'assignment-2', roleGuid: 'role-2', scopeType: 'global' }, + ]); + rolePermissionRepository.find.mockResolvedValue([ + { roleGuid: 'role-1', permissionCode: 'devices.view' }, + { roleGuid: 'role-2', permissionCode: 'devices.view' }, + ]); + + await expect( + service.getPermissionScope('actor', 'devices.view'), + ).resolves.toEqual({ + global: true, + deviceGroupGuids: new Set(), + }); + expect(assignmentGroupRepository.find).not.toHaveBeenCalled(); + }); + + it('ignores a damaged device-group grant for a global-only action', async () => { + assignmentRepository.find.mockResolvedValue([ + { guid: 'assignment-1', roleGuid: 'role-1', scopeType: 'device_group' }, + ]); + rolePermissionRepository.find.mockResolvedValue([ + { roleGuid: 'role-1', permissionCode: 'users.edit' }, + ]); + assignmentGroupRepository.find.mockResolvedValue([ + { assignmentGuid: 'assignment-1', deviceGroupGuid: 'group-1' }, + ]); + + await expect( + service.requirePermission('actor', 'users.edit'), + ).rejects.toThrow('无权限访问'); + expect(assignmentGroupRepository.find).not.toHaveBeenCalled(); + }); + + it('rejects direct and batch access outside the selected groups', async () => { + assignmentRepository.find.mockResolvedValue([ + { guid: 'assignment-1', roleGuid: 'role-1', scopeType: 'device_group' }, + ]); + rolePermissionRepository.find.mockResolvedValue([ + { roleGuid: 'role-1', permissionCode: 'devices.delete' }, + ]); + assignmentGroupRepository.find.mockResolvedValue([ + { assignmentGuid: 'assignment-1', deviceGroupGuid: 'group-1' }, + ]); + peerRepository.findOne.mockResolvedValue({ + uuid: 'peer-2', + deviceGroupGuid: 'group-2', + }); + + await expect( + service.assertDeviceAccess('actor', 'devices.delete', 'peer-2'), + ).rejects.toThrow('设备不在授权设备组内'); + + peerRepository.find.mockResolvedValue([ + { uuid: 'peer-1', deviceGroupGuid: 'group-1' }, + { uuid: 'peer-2', deviceGroupGuid: 'group-2' }, + ] as Peer[]); + await expect( + service.assertDevicesAccess('actor', 'devices.delete', [ + 'peer-1', + 'peer-2', + ]), + ).rejects.toThrow('批量请求包含未授权设备'); + expect(auditService.recordDenied).toHaveBeenCalledWith( + expect.objectContaining({ + actorUserGuid: 'actor', + targetType: 'device', + targetGuid: 'peer-2', + action: 'devices.delete', + }), + ); + }); + + it('reflects role revocation on the next authorization request', async () => { + assignmentRepository.find + .mockResolvedValueOnce([ + { guid: 'assignment-1', roleGuid: 'role-1', scopeType: 'global' }, + ]) + .mockResolvedValueOnce([]); + rolePermissionRepository.find.mockResolvedValue([ + { roleGuid: 'role-1', permissionCode: 'strategies.view' }, + ]); + + await expect( + service.requirePermission('actor', 'strategies.view'), + ).resolves.toEqual({ global: true, deviceGroupGuids: new Set() }); + await expect( + service.requirePermission('actor', 'strategies.view'), + ).rejects.toThrow('无权限访问'); + }); + + it('requires a separate permission for sensitive fields on user updates', async () => { + assignmentRepository.find.mockResolvedValue([ + { guid: 'assignment-1', roleGuid: 'role-1', scopeType: 'global' }, + ]); + rolePermissionRepository.find.mockImplementation(({ where }) => + where.permissionCode === 'users.edit' + ? [{ roleGuid: 'role-1', permissionCode: 'users.edit' }] + : [], + ); + userRepository.findOne + .mockResolvedValueOnce(activeUser) + .mockResolvedValueOnce(activeUser) + .mockResolvedValueOnce({ guid: 'target', isAdmin: false }); + + await expect( + service.assertUserMutation('actor', 'target', 'users.edit', { + status: UserStatus.DISABLED, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(rolePermissionRepository.find).toHaveBeenCalled(); + }); + + it('requires global scope for strategy assignment to a user', async () => { + assignmentRepository.find.mockResolvedValue([ + { guid: 'assignment-1', roleGuid: 'role-1', scopeType: 'device_group' }, + ]); + rolePermissionRepository.find.mockResolvedValue([ + { roleGuid: 'role-1', permissionCode: 'strategies.assign' }, + ]); + assignmentGroupRepository.find.mockResolvedValue([ + { assignmentGuid: 'assignment-1', deviceGroupGuid: 'group-1' }, + ]); + + await expect( + service.assertStrategyTargets('actor', 'user', ['user-1']), + ).rejects.toThrow('按用户分配策略需要全局权限'); + }); + + it('protects administrator users from global strategy assignment', async () => { + assignmentRepository.find.mockResolvedValue([ + { guid: 'assignment-1', roleGuid: 'role-1', scopeType: 'global' }, + ]); + rolePermissionRepository.find.mockResolvedValue([ + { roleGuid: 'role-1', permissionCode: 'strategies.assign' }, + ]); + userRepository.find.mockResolvedValue([ + { guid: 'protected-user', isAdmin: true }, + ]); + + await expect( + service.assertStrategyTargets('actor', 'user', ['protected-user']), + ).rejects.toThrow('需要超级管理员权限'); + expect(auditService.recordDenied).toHaveBeenCalledWith( + expect.objectContaining({ + actorUserGuid: 'actor', + targetGuid: 'protected-user', + action: 'super_admin', + }), + ); + }); + + it('protects administrator users on alternate batch mutation paths', async () => { + assignmentRepository.find.mockResolvedValue([ + { guid: 'assignment-1', roleGuid: 'role-1', scopeType: 'global' }, + ]); + rolePermissionRepository.find.mockResolvedValue([ + { roleGuid: 'role-1', permissionCode: 'user_groups.membership' }, + ]); + userRepository.find.mockResolvedValue([ + { guid: 'protected-user', isAdmin: true }, + ]); + + await expect( + service.assertUsersMutation( + 'actor', + ['protected-user'], + 'user_groups.membership', + ), + ).rejects.toThrow('需要超级管理员权限'); + }); + + it('does not expose unknown persisted permission rows as effective grants', async () => { + assignmentRepository.find.mockResolvedValue([ + { guid: 'assignment-1', roleGuid: 'role-1', scopeType: 'global' }, + ]); + rolePermissionRepository.find.mockResolvedValue([ + { roleGuid: 'role-1', permissionCode: 'future.admin' }, + { roleGuid: 'role-1', permissionCode: 'devices.view' }, + ]); + + const result = await service.getEffectivePermissions('actor'); + expect(result.permissions).toEqual(['devices.view']); + expect(result.scopes['future.admin']).toBeUndefined(); + expect( + PERMISSION_CATALOG.map((permission) => permission.code), + ).not.toContain('future.admin'); + }); +}); + +describe('UserRoleService', () => { + it('does not present global-only role permissions as device-group grants', async () => { + const userRepository = repository(); + const roleRepository = repository(); + const rolePermissionRepository = repository(); + const assignmentRepository = repository(); + const assignmentGroupRepository = repository(); + const deviceGroupRepository = repository(); + userRepository.exist.mockResolvedValue(true); + assignmentRepository.find.mockResolvedValue([ + { + guid: 'assignment-1', + userGuid: 'user-1', + roleGuid: 'role-1', + scopeType: 'device_group', + }, + ]); + roleRepository.find.mockResolvedValue([ + { guid: 'role-1', name: 'Device operator' }, + ] as Role[]); + rolePermissionRepository.find.mockResolvedValue([ + { roleGuid: 'role-1', permissionCode: 'devices.view' }, + { roleGuid: 'role-1', permissionCode: 'users.edit' }, + ]); + assignmentGroupRepository.find.mockResolvedValue([ + { assignmentGuid: 'assignment-1', deviceGroupGuid: 'group-1' }, + ]); + + const service = new UserRoleService( + userRepository as unknown as Repository, + roleRepository as unknown as Repository, + rolePermissionRepository as unknown as Repository, + assignmentRepository as unknown as Repository, + assignmentGroupRepository as unknown as Repository, + deviceGroupRepository as unknown as Repository, + {} as DataSource, + {} as RbacAuditService, + {} as RbacAuthorizationService, + ); + + const result = await service.getUserRoles('user-1'); + expect(result.data[0].permissions).toEqual(['devices.view']); + expect(result.effective_scope).toEqual({ + 'devices.view': { + scope_type: 'device_group', + device_group_guids: ['group-1'], + }, + }); + }); +}); + +describe('RoleService', () => { + it('does not add global-only permissions to a role with scoped assignments', async () => { + const roleRepository = repository(); + const rolePermissionRepository = repository(); + const assignmentRepository = repository(); + const assignmentGroupRepository = repository(); + roleRepository.findOne.mockResolvedValue({ guid: 'role-1' }); + assignmentRepository.exist.mockResolvedValue(true); + const transaction = jest.fn(); + const service = new RoleService( + roleRepository as unknown as Repository, + rolePermissionRepository as unknown as Repository, + assignmentRepository as unknown as Repository, + assignmentGroupRepository as unknown as Repository, + { transaction } as unknown as DataSource, + {} as RbacAuditService, + { + requireSuperAdmin: jest.fn().mockResolvedValue(undefined), + } as unknown as RbacAuthorizationService, + ); + + await expect( + service.replaceRolePermissions('role-1', ['users.edit'], 'actor'), + ).rejects.toThrow('已有高级范围授权'); + expect(transaction).not.toHaveBeenCalled(); + }); +}); + +describe('RbacAuditService', () => { + it('redacts credentials before they are persisted', async () => { + const auditRepository = repository(); + const service = new RbacAuditService( + auditRepository as unknown as Repository, + ); + + await service.record({ + actorUserGuid: 'actor', + targetType: 'role', + action: 'role.update', + result: 'allowed', + afterState: { + password: 'secret', + nested: { token: 'jwt', visible: true }, + }, + }); + + expect(auditRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + afterState: JSON.stringify({ + password: '[REDACTED]', + nested: { token: '[REDACTED]', visible: true }, + }), + }), + ); + }); + + it('keeps connection-audit mutation super-admin-only', () => { + expect( + Reflect.getMetadata( + REQUIRE_SUPER_ADMIN_KEY, + AuditsController.prototype.updateConnectionAudit, + ), + ).toBe(true); + }); + + it('keeps the catalog protected while exposing only the caller effective grants', () => { + expect( + Reflect.getMetadata( + REQUIRE_SUPER_ADMIN_KEY, + PermissionController.prototype.getPermissions, + ), + ).toBe(true); + expect( + Reflect.getMetadata( + REQUIRE_SUPER_ADMIN_KEY, + PermissionController.prototype.getMyPermissions, + ), + ).toBeUndefined(); + }); + + it('keeps global dashboard aggregates super-administrator-only', () => { + expect( + Reflect.getMetadata(REQUIRE_SUPER_ADMIN_KEY, DashboardController), + ).toBe(true); + }); +}); + +describe('DeviceGroupController current-state authorization', () => { + const query = { current: 1, pageSize: 20 }; + + const createController = () => { + const deviceGroupService = { + getAccessibleDeviceGroups: jest.fn(), + getDevices: jest.fn(), + }; + const authorizationService = { + getCurrentUser: jest.fn().mockResolvedValue({ isAdmin: true }), + getPermissionScope: jest.fn().mockResolvedValue({ + global: true, + deviceGroupGuids: new Set(), + }), + }; + return { + controller: new DeviceGroupController( + deviceGroupService as never, + {} as never, + {} as never, + {} as never, + authorizationService as never, + ), + deviceGroupService, + authorizationService, + }; + }; + + it('uses the current strategy-assignment scope for the group list', async () => { + const { controller, deviceGroupService, authorizationService } = + createController(); + + await controller.getDeviceGroups('actor', query); + + const scope = + await authorizationService.getPermissionScope.mock.results[0].value; + expect(deviceGroupService.getAccessibleDeviceGroups).toHaveBeenCalledWith( + 'actor', + query, + true, + scope, + ); + }); + + it('uses only the current RBAC scope for the delegated device list', async () => { + const { controller, deviceGroupService, authorizationService } = + createController(); + + await controller.getDevices('actor', query); + + const scope = + await authorizationService.getPermissionScope.mock.results[0].value; + expect(deviceGroupService.getDevices).toHaveBeenCalledWith( + 'actor', + query, + true, + scope, + ); + }); +}); diff --git a/src/modules/rbac/rbac.module.ts b/src/modules/rbac/rbac.module.ts new file mode 100644 index 0000000..4b38022 --- /dev/null +++ b/src/modules/rbac/rbac.module.ts @@ -0,0 +1,43 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Peer } from '../../common/entities/peer.entity'; +import { DeviceGroup } from '../device-group/entities/device-group.entity'; +import { User } from '../user/entities/user.entity'; +import { PermissionController } from './permission.controller'; +import { RoleController } from './role.controller'; +import { UserRoleController } from './user-role.controller'; +import { ConsoleAudit } from './entities/console-audit.entity'; +import { Role } from './entities/role.entity'; +import { RolePermission } from './entities/role-permission.entity'; +import { UserRoleAssignment } from './entities/user-role-assignment.entity'; +import { UserRoleAssignmentDeviceGroup } from './entities/user-role-assignment-device-group.entity'; +import { RbacGuard } from './guards/rbac.guard'; +import { RbacAuditService } from './services/rbac-audit.service'; +import { RbacAuthorizationService } from './services/rbac-authorization.service'; +import { RoleService } from './services/role.service'; +import { UserRoleService } from './services/user-role.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Role, + RolePermission, + UserRoleAssignment, + UserRoleAssignmentDeviceGroup, + ConsoleAudit, + User, + DeviceGroup, + Peer, + ]), + ], + controllers: [PermissionController, RoleController, UserRoleController], + providers: [ + RbacAuditService, + RbacAuthorizationService, + RoleService, + UserRoleService, + RbacGuard, + ], + exports: [RbacAuditService, RbacAuthorizationService, RbacGuard], +}) +export class RbacModule {} diff --git a/src/modules/rbac/role.controller.ts b/src/modules/rbac/role.controller.ts new file mode 100644 index 0000000..4448eb9 --- /dev/null +++ b/src/modules/rbac/role.controller.ts @@ -0,0 +1,79 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, + Query, +} from '@nestjs/common'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireSuperAdmin } from './decorators/require-permission.decorator'; +import { + CreateRoleDto, + ReplaceRolePermissionsDto, + RoleQueryDto, + UpdateRoleDto, +} from './dto/role.dto'; +import { RoleService } from './services/role.service'; + +@Controller('roles') +@RequireSuperAdmin() +export class RoleController { + constructor(private readonly roleService: RoleService) {} + + @Get() + list(@Query() query: RoleQueryDto) { + return this.roleService.listRoles(query); + } + + @Get(':guid') + get(@Param('guid', new ParseUUIDPipe({ version: '4' })) guid: string) { + return this.roleService.getRole(guid); + } + + @Post() + @HttpCode(HttpStatus.OK) + create(@Body() dto: CreateRoleDto, @CurrentUser('id') actorGuid: string) { + return this.roleService.createRole(dto, actorGuid); + } + + @Patch(':guid') + @HttpCode(HttpStatus.OK) + update( + @Param('guid', new ParseUUIDPipe({ version: '4' })) guid: string, + @Body() dto: UpdateRoleDto, + @CurrentUser('id') actorGuid: string, + ) { + return this.roleService.updateRole(guid, dto, actorGuid); + } + + @Delete(':guid') + @HttpCode(HttpStatus.OK) + async remove( + @Param('guid', new ParseUUIDPipe({ version: '4' })) guid: string, + @CurrentUser('id') actorGuid: string, + ) { + await this.roleService.deleteRole(guid, actorGuid); + return { message: '角色已删除' }; + } + + @Put(':guid/permissions') + @HttpCode(HttpStatus.OK) + replacePermissions( + @Param('guid', new ParseUUIDPipe({ version: '4' })) guid: string, + @Body() body: ReplaceRolePermissionsDto, + @CurrentUser('id') actorGuid: string, + ) { + return this.roleService.replaceRolePermissions( + guid, + body.permissions, + actorGuid, + ); + } +} diff --git a/src/modules/rbac/services/rbac-audit.service.ts b/src/modules/rbac/services/rbac-audit.service.ts new file mode 100644 index 0000000..25b1055 --- /dev/null +++ b/src/modules/rbac/services/rbac-audit.service.ts @@ -0,0 +1,128 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { EntityManager, Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; +import { ConsoleAudit } from '../entities/console-audit.entity'; + +export interface RbacAuditEvent { + actorUserGuid?: string | null; + targetType: string; + targetGuid?: string | null; + action: string; + result: 'allowed' | 'denied'; + reason?: string | null; + beforeState?: unknown; + afterState?: unknown; + requestId?: string | null; +} + +@Injectable() +export class RbacAuditService { + private readonly logger = new Logger(RbacAuditService.name); + + constructor( + @InjectRepository(ConsoleAudit) + private readonly repository: Repository, + ) {} + + async record( + event: RbacAuditEvent, + manager?: EntityManager, + ): Promise { + const repository = manager?.getRepository(ConsoleAudit) || this.repository; + const audit = repository.create({ + guid: uuidv4(), + actorUserGuid: event.actorUserGuid ?? null, + targetType: event.targetType, + targetGuid: event.targetGuid ?? null, + action: event.action, + result: event.result, + reason: event.reason ?? null, + beforeState: this.serializeState(event.beforeState), + afterState: this.serializeState(event.afterState), + requestId: event.requestId ?? null, + }); + return repository.save(audit); + } + + async recordDenied(event: Omit): Promise { + try { + await this.record({ ...event, result: 'denied' }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Unable to persist denied RBAC audit: ${message}`); + } + } + + async query(filters: { + operator?: string; + pageSize?: number; + current?: number; + created_at?: string; + }): Promise<{ data: Record[]; total: number }> { + const pageSize = filters.pageSize || 20; + const current = filters.current || 1; + const query = this.repository.createQueryBuilder('audit'); + if (filters.operator) { + query.andWhere('audit.actorUserGuid LIKE :operator', { + operator: `%${filters.operator}%`, + }); + } + if (filters.created_at) { + query.andWhere('audit.createdAt >= :createdAt', { + createdAt: new Date(filters.created_at), + }); + } + const [rows, total] = await query + .orderBy('audit.createdAt', 'DESC') + .skip((current - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + return { + data: rows.map((row) => ({ + guid: row.guid, + actor_user_guid: row.actorUserGuid, + target_type: row.targetType, + target_guid: row.targetGuid, + action: row.action, + result: row.result, + reason: row.reason, + before_state: this.parseState(row.beforeState), + after_state: this.parseState(row.afterState), + request_id: row.requestId, + created_at: row.createdAt, + })), + total, + }; + } + + private serializeState(value: unknown): string | null { + if (value === undefined || value === null) return null; + return JSON.stringify(this.redact(value)); + } + + private parseState(value: string | null): unknown { + if (!value) return null; + try { + return JSON.parse(value) as unknown; + } catch { + return null; + } + } + + private redact(value: unknown): unknown { + if (Array.isArray(value)) return value.map((item) => this.redact(item)); + if (!value || typeof value !== 'object') return value; + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + if ( + /(password|token|secret|verifier|credential|authorization)/i.test(key) + ) { + result[key] = '[REDACTED]'; + } else { + result[key] = this.redact(item); + } + } + return result; + } +} diff --git a/src/modules/rbac/services/rbac-authorization.service.ts b/src/modules/rbac/services/rbac-authorization.service.ts new file mode 100644 index 0000000..2b2724e --- /dev/null +++ b/src/modules/rbac/services/rbac-authorization.service.ts @@ -0,0 +1,504 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { Peer } from '../../../common/entities/peer.entity'; +import { User, UserStatus } from '../../user/entities/user.entity'; +import { DeviceGroup } from '../../device-group/entities/device-group.entity'; +import { RolePermission } from '../entities/role-permission.entity'; +import { UserRoleAssignment } from '../entities/user-role-assignment.entity'; +import { UserRoleAssignmentDeviceGroup } from '../entities/user-role-assignment-device-group.entity'; +import { + DEVICE_SCOPED_PERMISSION_CODES, + isKnownPermissionCode, + PERMISSION_CATALOG, + PermissionCode, +} from '../constants/permission-catalog'; +import { RbacAuditService } from './rbac-audit.service'; + +export interface EffectivePermissionScope { + scope_type: 'global' | 'device_group' | 'none'; + device_group_guids: string[]; +} + +export interface PermissionScope { + global: boolean; + deviceGroupGuids: Set; +} + +@Injectable() +export class RbacAuthorizationService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectRepository(RolePermission) + private readonly rolePermissionRepository: Repository, + @InjectRepository(UserRoleAssignment) + private readonly assignmentRepository: Repository, + @InjectRepository(UserRoleAssignmentDeviceGroup) + private readonly assignmentGroupRepository: Repository, + @InjectRepository(Peer) + private readonly peerRepository: Repository, + @InjectRepository(DeviceGroup) + private readonly deviceGroupRepository: Repository, + private readonly auditService: RbacAuditService, + ) {} + + async getCurrentUser(userGuid: string): Promise { + const user = await this.userRepository.findOne({ + where: { guid: userGuid }, + }); + if (!user || user.status !== UserStatus.ACTIVE) { + throw new UnauthorizedException('账户不存在或已被禁用'); + } + return user; + } + + async requireSuperAdmin(userGuid: string): Promise { + const user = await this.getCurrentUser(userGuid); + if (!user.isAdmin) { + throw new ForbiddenException('需要超级管理员权限'); + } + return user; + } + + async getPermissionScope( + userGuid: string, + permissionCode: string, + ): Promise { + const user = await this.getCurrentUser(userGuid); + if (user.isAdmin || !isKnownPermissionCode(permissionCode)) { + return { + global: user.isAdmin === true, + deviceGroupGuids: new Set(), + }; + } + + const allAssignments = await this.assignmentRepository.find({ + where: { userGuid }, + select: ['guid', 'roleGuid', 'scopeType'], + }); + const assignments = allAssignments.length + ? allAssignments.filter( + (assignment) => + assignment.scopeType === 'global' || + assignment.scopeType === 'device_group', + ) + : []; + const rolePermissions = assignments.length + ? await this.rolePermissionRepository.find({ + where: { + roleGuid: In(assignments.map((assignment) => assignment.roleGuid)), + permissionCode, + }, + }) + : []; + const matchingRoleGuids = new Set( + rolePermissions.map((permission) => permission.roleGuid), + ); + const matchingAssignments = assignments.filter( + (assignment) => + matchingRoleGuids.has(assignment.roleGuid) && + (assignment.scopeType === 'global' || + DEVICE_SCOPED_PERMISSION_CODES.has(permissionCode)), + ); + + const global = matchingAssignments.some( + (assignment) => assignment.scopeType === 'global', + ); + if (global) { + return { global: true, deviceGroupGuids: new Set() }; + } + + const scopedAssignments = matchingAssignments.filter( + (assignment) => assignment.scopeType === 'device_group', + ); + if (!scopedAssignments.length) { + return { global: false, deviceGroupGuids: new Set() }; + } + + const groups = await this.assignmentGroupRepository.find({ + where: { + assignmentGuid: In( + scopedAssignments.map((assignment) => assignment.guid), + ), + }, + select: ['deviceGroupGuid'], + }); + + return { + global: false, + deviceGroupGuids: new Set(groups.map((group) => group.deviceGroupGuid)), + }; + } + + async requirePermission( + userGuid: string, + permissionCode: string, + ): Promise { + if (!isKnownPermissionCode(permissionCode)) { + throw new ForbiddenException('未知权限'); + } + const scope = await this.getPermissionScope(userGuid, permissionCode); + if (!scope.global && scope.deviceGroupGuids.size === 0) { + throw new ForbiddenException('无权限访问'); + } + return scope; + } + + async getEffectivePermissions(userGuid: string): Promise<{ + permissions: string[]; + scopes: Record; + }> { + const user = await this.getCurrentUser(userGuid); + if (user.isAdmin) { + const scopes = Object.fromEntries( + PERMISSION_CATALOG.map((permission) => [ + permission.code, + { scope_type: 'global', device_group_guids: [] }, + ]), + ) as Record; + return { + permissions: PERMISSION_CATALOG.map((permission) => permission.code), + scopes, + }; + } + + const assignments = await this.assignmentRepository.find({ + where: { userGuid }, + select: ['guid', 'roleGuid', 'scopeType'], + }); + const rows = assignments.length + ? await this.rolePermissionRepository.find({ + where: { + roleGuid: In(assignments.map((assignment) => assignment.roleGuid)), + }, + }) + : []; + const permissions = new Set(); + const assignmentIds = new Set(); + const scopeRows = new Map }>(); + const assignmentByRole = new Map( + assignments.map((assignment) => [assignment.roleGuid, assignment]), + ); + const permissionAssignmentRows: Array<{ + assignmentGuid: string; + scopeType: 'global' | 'device_group'; + permissionCode: string; + }> = []; + for (const row of rows) { + if (!isKnownPermissionCode(row.permissionCode)) continue; + const assignment = assignmentByRole.get(row.roleGuid); + if (!assignment) continue; + if ( + assignment.scopeType !== 'global' && + assignment.scopeType !== 'device_group' + ) { + continue; + } + if ( + assignment.scopeType === 'device_group' && + !DEVICE_SCOPED_PERMISSION_CODES.has(row.permissionCode) + ) { + continue; + } + permissions.add(row.permissionCode); + assignmentIds.add(assignment.guid); + permissionAssignmentRows.push({ + assignmentGuid: assignment.guid, + scopeType: assignment.scopeType, + permissionCode: row.permissionCode, + }); + const current = scopeRows.get(row.permissionCode) || { + global: false, + ids: new Set(), + }; + if (assignment.scopeType === 'global') current.global = true; + scopeRows.set(row.permissionCode, current); + } + if (assignmentIds.size) { + const groups = await this.assignmentGroupRepository.find({ + where: { assignmentGuid: In([...assignmentIds]) }, + select: ['assignmentGuid', 'deviceGroupGuid'], + }); + const assignmentPermission = new Map(); + for (const row of permissionAssignmentRows) { + const list = assignmentPermission.get(row.assignmentGuid) || []; + list.push(row.permissionCode); + assignmentPermission.set(row.assignmentGuid, list); + } + for (const group of groups) { + for (const permission of assignmentPermission.get( + group.assignmentGuid, + ) || []) { + scopeRows.get(permission)?.ids.add(group.deviceGroupGuid); + } + } + } + const scopes: Record = {}; + for (const [permission, scope] of scopeRows) { + scopes[permission] = scope.global + ? { scope_type: 'global', device_group_guids: [] } + : { + scope_type: 'device_group', + device_group_guids: [...scope.ids].sort(), + }; + } + return { permissions: [...permissions].sort(), scopes }; + } + + async assertDeviceAccess( + userGuid: string, + permissionCode: PermissionCode, + deviceUuid: string, + ): Promise { + const scope = await this.requirePermission(userGuid, permissionCode); + const peer = await this.peerRepository.findOne({ + where: { uuid: deviceUuid }, + }); + if (!peer) throw new NotFoundException('设备不存在'); + if ( + !scope.global && + (!peer.deviceGroupGuid || + !scope.deviceGroupGuids.has(peer.deviceGroupGuid)) + ) { + return this.rejectWithAudit( + userGuid, + 'device', + deviceUuid, + permissionCode, + new ForbiddenException('设备不在授权设备组内'), + ); + } + return peer; + } + + async assertDevicesAccess( + userGuid: string, + permissionCode: PermissionCode, + deviceUuids: string[], + ): Promise { + const scope = await this.requirePermission(userGuid, permissionCode); + const unique = [...new Set(deviceUuids)]; + if (!unique.length) return []; + const peers = await this.peerRepository.find({ + where: { uuid: In(unique) }, + }); + if (!scope.global) { + const denied = peers.find( + (peer) => + !peer.deviceGroupGuid || + !scope.deviceGroupGuids.has(peer.deviceGroupGuid), + ); + if (denied) { + return this.rejectWithAudit( + userGuid, + 'device', + denied.uuid, + permissionCode, + new ForbiddenException('批量请求包含未授权设备'), + ); + } + } + return peers; + } + + async assertStrategyTargets( + userGuid: string, + targetType: 'device' | 'user' | 'device_group', + targetGuids: string[], + ): Promise { + const scope = await this.requirePermission(userGuid, 'strategies.assign'); + if (targetType === 'user') { + if (!scope.global) { + return this.rejectWithAudit( + userGuid, + 'user', + targetGuids[0] || null, + 'strategies.assign', + new ForbiddenException('按用户分配策略需要全局权限'), + ); + } + const users = await this.userRepository.find({ + where: { guid: In([...new Set(targetGuids)]) }, + select: ['guid', 'isAdmin'], + }); + const protectedUser = users.find((user) => user.isAdmin); + if (protectedUser) { + try { + await this.requireSuperAdmin(userGuid); + } catch (error: unknown) { + return this.rejectWithAudit( + userGuid, + 'user', + protectedUser.guid, + 'super_admin', + error, + ); + } + } + return; + } + if (scope.global) return; + if (targetType === 'device_group') { + const requested = [...new Set(targetGuids)]; + const groups = await this.deviceGroupRepository.find({ + where: { guid: In(requested) }, + select: ['guid'], + }); + const selected = new Set(scope.deviceGroupGuids); + const existing = new Set(groups.map((group) => group.guid)); + const deniedGuid = requested.find( + (guid) => !existing.has(guid) || !selected.has(guid), + ); + if (deniedGuid) { + return this.rejectWithAudit( + userGuid, + 'device_group', + deniedGuid, + 'strategies.assign', + new ForbiddenException('目标设备组不在授权范围内'), + ); + } + return; + } + const peers = await this.peerRepository.find({ + where: { uuid: In([...new Set(targetGuids)]) }, + select: ['uuid', 'deviceGroupGuid'], + }); + const denied = peers.find( + (peer) => + !peer.deviceGroupGuid || + !scope.deviceGroupGuids.has(peer.deviceGroupGuid), + ); + if (denied) { + return this.rejectWithAudit( + userGuid, + 'device', + denied.uuid, + 'strategies.assign', + new ForbiddenException('目标设备不在授权设备组内'), + ); + } + } + + async assertUserMutation( + actorGuid: string, + targetGuid: string, + permissionCode: PermissionCode, + changes?: { + is_admin?: boolean; + status?: unknown; + user_group_guid?: unknown; + }, + ): Promise { + await this.requirePermission(actorGuid, permissionCode); + + // The legacy user update endpoint accepts several fields. Keep each + // sensitive field behind its own action so `users.edit` cannot silently + // become a status or group-membership grant. + if (changes?.status !== undefined && permissionCode !== 'users.status') { + try { + await this.requirePermission(actorGuid, 'users.status'); + } catch (error: unknown) { + return this.rejectWithAudit( + actorGuid, + 'user', + targetGuid, + 'users.status', + error, + ); + } + } + if ( + changes?.user_group_guid !== undefined && + permissionCode !== 'user_groups.membership' + ) { + try { + await this.requirePermission(actorGuid, 'user_groups.membership'); + } catch (error: unknown) { + return this.rejectWithAudit( + actorGuid, + 'user', + targetGuid, + 'user_groups.membership', + error, + ); + } + } + + const target = await this.userRepository.findOne({ + where: { guid: targetGuid }, + select: ['guid', 'isAdmin'], + }); + if (!target) throw new NotFoundException('用户不存在'); + if (target.isAdmin || changes?.is_admin !== undefined) { + try { + await this.requireSuperAdmin(actorGuid); + } catch (error: unknown) { + return this.rejectWithAudit( + actorGuid, + 'user', + targetGuid, + 'super_admin', + error, + ); + } + } + } + + /** + * Pre-authorize a user batch before the owning service performs any write. + * Missing identifiers are intentionally ignored here so existing partial + * batch responses can report them after all existing targets are checked. + */ + async assertUsersMutation( + actorGuid: string, + targetGuids: string[], + permissionCode: PermissionCode, + ): Promise { + await this.requirePermission(actorGuid, permissionCode); + const uniqueGuids = [...new Set(targetGuids)]; + if (!uniqueGuids.length) return; + + const users = await this.userRepository.find({ + where: { guid: In(uniqueGuids) }, + select: ['guid', 'isAdmin'], + }); + const protectedUser = users.find((user) => user.isAdmin); + if (protectedUser) { + try { + await this.requireSuperAdmin(actorGuid); + } catch (error: unknown) { + return this.rejectWithAudit( + actorGuid, + 'user', + protectedUser.guid, + 'super_admin', + error, + ); + } + } + } + + private async rejectWithAudit( + actorUserGuid: string, + targetType: string, + targetGuid: string | null, + action: string, + error: unknown, + ): Promise { + await this.auditService.recordDenied({ + actorUserGuid, + targetType, + targetGuid, + action, + reason: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} diff --git a/src/modules/rbac/services/role.service.ts b/src/modules/rbac/services/role.service.ts new file mode 100644 index 0000000..aeda45d --- /dev/null +++ b/src/modules/rbac/services/role.service.ts @@ -0,0 +1,371 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, In, QueryFailedError, Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; +import { Role } from '../entities/role.entity'; +import { RolePermission } from '../entities/role-permission.entity'; +import { UserRoleAssignment } from '../entities/user-role-assignment.entity'; +import { UserRoleAssignmentDeviceGroup } from '../entities/user-role-assignment-device-group.entity'; +import { CreateRoleDto, RoleQueryDto, UpdateRoleDto } from '../dto/role.dto'; +import { + DEVICE_SCOPED_PERMISSION_CODES, + PermissionCode, + isKnownPermissionCode, +} from '../constants/permission-catalog'; +import { RbacAuditService } from './rbac-audit.service'; +import { RbacAuthorizationService } from './rbac-authorization.service'; + +@Injectable() +export class RoleService { + constructor( + @InjectRepository(Role) + private readonly roleRepository: Repository, + @InjectRepository(RolePermission) + private readonly rolePermissionRepository: Repository, + @InjectRepository(UserRoleAssignment) + private readonly assignmentRepository: Repository, + @InjectRepository(UserRoleAssignmentDeviceGroup) + private readonly assignmentGroupRepository: Repository, + private readonly dataSource: DataSource, + private readonly auditService: RbacAuditService, + private readonly authorizationService: RbacAuthorizationService, + ) {} + + async listRoles(query: RoleQueryDto) { + const current = query.current || 1; + const pageSize = query.pageSize || 20; + const builder = this.roleRepository.createQueryBuilder('role'); + if (query.search) { + builder.andWhere('role.name LIKE :search', { + search: `%${query.search}%`, + }); + } + const [roles, total] = await builder + .orderBy('role.name', 'ASC') + .addOrderBy('role.guid', 'ASC') + .skip((current - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + const permissions = roles.length + ? await this.rolePermissionRepository.find({ + where: { roleGuid: In(roles.map((role) => role.guid)) }, + }) + : []; + const permissionMap = this.groupPermissions(permissions); + return { + data: roles.map((role) => + this.toResponse(role, permissionMap.get(role.guid) || []), + ), + total, + }; + } + + async getRole(guid: string) { + const role = await this.requireRole(guid); + const permissions = await this.rolePermissionRepository.find({ + where: { roleGuid: guid }, + }); + return this.toResponse( + role, + permissions + .map((permission) => permission.permissionCode) + .filter(isKnownPermissionCode) + .sort(), + ); + } + + async createRole(dto: CreateRoleDto, actorGuid: string) { + await this.authorizationService.requireSuperAdmin(actorGuid); + const name = this.normalizeName(dto.name); + const permissions = this.validatePermissions(dto.permissions); + await this.ensureNameAvailable(name); + return this.dataSource.transaction(async (manager) => { + const role = manager.getRepository(Role).create({ + guid: uuidv4(), + name, + note: dto.note?.trim() || null, + }); + try { + await manager.getRepository(Role).save(role); + } catch (error: unknown) { + if (this.isUniqueError(error)) + throw new ConflictException('角色名称已存在'); + throw error; + } + await this.replacePermissionsWithManager(manager, role.guid, permissions); + await this.auditService.record( + { + actorUserGuid: actorGuid, + targetType: 'role', + targetGuid: role.guid, + action: 'role.create', + result: 'allowed', + afterState: { name: role.name, note: role.note, permissions }, + }, + manager, + ); + return this.toResponse(role, permissions); + }); + } + + async updateRole(guid: string, dto: UpdateRoleDto, actorGuid: string) { + await this.authorizationService.requireSuperAdmin(actorGuid); + const role = await this.requireRole(guid); + const beforePermissions = await this.getPermissionCodes(guid); + const beforeName = role.name; + const beforeNote = role.note; + const name = + dto.name === undefined ? role.name : this.normalizeName(dto.name); + if (name !== role.name) await this.ensureNameAvailable(name, guid); + const permissions = + dto.permissions === undefined + ? beforePermissions + : this.validatePermissions(dto.permissions); + if (dto.permissions !== undefined) { + await this.ensureScopedAssignmentsRemainValid(guid, permissions); + } + return this.dataSource.transaction(async (manager) => { + role.name = name; + if (dto.note !== undefined) role.note = dto.note.trim() || null; + await manager.getRepository(Role).save(role); + if (dto.permissions !== undefined) { + await this.replacePermissionsWithManager(manager, guid, permissions); + } + await this.auditService.record( + { + actorUserGuid: actorGuid, + targetType: 'role', + targetGuid: guid, + action: 'role.update', + result: 'allowed', + beforeState: { + name: beforeName, + note: beforeNote, + permissions: beforePermissions, + }, + afterState: { name: role.name, note: role.note, permissions }, + }, + manager, + ); + return this.toResponse(role, permissions); + }); + } + + async deleteRole(guid: string, actorGuid: string): Promise { + await this.authorizationService.requireSuperAdmin(actorGuid); + await this.dataSource.transaction(async (manager) => { + const roleRepository = manager.getRepository(Role); + const permissionRepository = manager.getRepository(RolePermission); + const assignmentRepository = manager.getRepository(UserRoleAssignment); + const assignmentGroupRepository = manager.getRepository( + UserRoleAssignmentDeviceGroup, + ); + const role = await roleRepository.findOne({ where: { guid } }); + if (!role) throw new NotFoundException('角色不存在'); + + // Take the complete pre-delete snapshot in the same transaction as the + // destructive writes so the audit record explains exactly which grants + // and scopes were revoked. + const [permissionRows, assignments] = await Promise.all([ + permissionRepository.find({ + where: { roleGuid: guid }, + select: ['permissionCode'], + }), + assignmentRepository.find({ + where: { roleGuid: guid }, + select: ['guid', 'userGuid', 'scopeType'], + }), + ]); + const assignmentGuids = assignments.map((assignment) => assignment.guid); + const assignmentGroups = assignmentGuids.length + ? await assignmentGroupRepository.find({ + where: { assignmentGuid: In(assignmentGuids) }, + select: ['assignmentGuid', 'deviceGroupGuid'], + }) + : []; + const groupsByAssignment = new Map(); + for (const group of assignmentGroups) { + const groups = groupsByAssignment.get(group.assignmentGuid) || []; + groups.push(group.deviceGroupGuid); + groupsByAssignment.set(group.assignmentGuid, groups); + } + const beforeState = { + name: role.name, + note: role.note, + permissions: permissionRows + .map((permission) => permission.permissionCode) + .sort(), + assignments: assignments + .map((assignment) => ({ + guid: assignment.guid, + user_guid: assignment.userGuid, + scope_type: assignment.scopeType, + device_group_guids: [ + ...(groupsByAssignment.get(assignment.guid) || []), + ].sort(), + })) + .sort((left, right) => left.guid.localeCompare(right.guid)), + }; + + if (assignments.length) { + await assignmentGroupRepository.delete({ + assignmentGuid: In(assignments.map((assignment) => assignment.guid)), + }); + await assignmentRepository.delete({ roleGuid: guid }); + } + await permissionRepository.delete({ roleGuid: guid }); + await roleRepository.delete({ guid }); + await this.auditService.record( + { + actorUserGuid: actorGuid, + targetType: 'role', + targetGuid: guid, + action: 'role.delete', + result: 'allowed', + beforeState, + }, + manager, + ); + }); + } + + async replaceRolePermissions( + guid: string, + permissions: string[], + actorGuid: string, + ) { + await this.authorizationService.requireSuperAdmin(actorGuid); + await this.requireRole(guid); + const validated = this.validatePermissions(permissions); + await this.ensureScopedAssignmentsRemainValid(guid, validated); + const before = await this.getPermissionCodes(guid); + await this.dataSource.transaction(async (manager) => { + await this.replacePermissionsWithManager(manager, guid, validated); + await this.auditService.record( + { + actorUserGuid: actorGuid, + targetType: 'role', + targetGuid: guid, + action: 'role.permissions.replace', + result: 'allowed', + beforeState: { permissions: before }, + afterState: { permissions: validated }, + }, + manager, + ); + }); + return this.getRole(guid); + } + + async getPermissionCodes(guid: string): Promise { + await this.requireRole(guid); + const rows = await this.rolePermissionRepository.find({ + where: { roleGuid: guid }, + }); + return rows + .map((row) => row.permissionCode) + .filter(isKnownPermissionCode) + .sort(); + } + + private async requireRole(guid: string): Promise { + const role = await this.roleRepository.findOne({ where: { guid } }); + if (!role) throw new NotFoundException('角色不存在'); + return role; + } + + private normalizeName(value: string): string { + const name = value.trim(); + if (!name) throw new BadRequestException('角色名称不能为空'); + return name; + } + + private async ensureNameAvailable(name: string, ignoredGuid?: string) { + const existing = await this.roleRepository + .createQueryBuilder('role') + .where('LOWER(role.name) = LOWER(:name)', { name }) + .getOne(); + if (existing && existing.guid !== ignoredGuid) { + throw new ConflictException('角色名称已存在'); + } + } + + private validatePermissions(permissions: string[]): PermissionCode[] { + const unique = [...new Set(permissions)]; + const unknown = unique.filter( + (permission) => !isKnownPermissionCode(permission), + ); + if (unknown.length) + throw new BadRequestException(`权限码不存在: ${unknown.join(', ')}`); + return unique.filter(isKnownPermissionCode).sort(); + } + + private async ensureScopedAssignmentsRemainValid( + roleGuid: string, + permissions: PermissionCode[], + ): Promise { + if ( + permissions.every((permission) => + DEVICE_SCOPED_PERMISSION_CODES.has(permission), + ) + ) { + return; + } + const hasScopedAssignment = await this.assignmentRepository.exist({ + where: { roleGuid, scopeType: 'device_group' }, + }); + if (hasScopedAssignment) { + throw new BadRequestException( + '已有高级范围授权的角色只能包含设备操作和 strategies.assign', + ); + } + } + + private async replacePermissionsWithManager( + manager: import('typeorm').EntityManager, + roleGuid: string, + permissions: string[], + ) { + await manager.delete(RolePermission, { roleGuid }); + if (permissions.length) { + await manager.insert( + RolePermission, + permissions.map((permissionCode) => ({ roleGuid, permissionCode })), + ); + } + } + + private groupPermissions(rows: RolePermission[]) { + const result = new Map(); + for (const row of rows) { + const list = result.get(row.roleGuid) || []; + if (isKnownPermissionCode(row.permissionCode)) + list.push(row.permissionCode); + result.set(row.roleGuid, list.sort()); + } + return result; + } + + private toResponse(role: Role, permissions: string[]) { + return { + guid: role.guid, + name: role.name, + note: role.note || '', + permissions, + created_at: role.createdAt, + updated_at: role.updatedAt, + }; + } + + private isUniqueError(error: unknown): boolean { + return ( + error instanceof QueryFailedError && + error.message.toUpperCase().includes('UNIQUE') + ); + } +} diff --git a/src/modules/rbac/services/user-role.service.ts b/src/modules/rbac/services/user-role.service.ts new file mode 100644 index 0000000..69ef0fd --- /dev/null +++ b/src/modules/rbac/services/user-role.service.ts @@ -0,0 +1,331 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, In, Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; +import { User } from '../../user/entities/user.entity'; +import { DeviceGroup } from '../../device-group/entities/device-group.entity'; +import { Role } from '../entities/role.entity'; +import { RolePermission } from '../entities/role-permission.entity'; +import { UserRoleAssignment } from '../entities/user-role-assignment.entity'; +import { UserRoleAssignmentDeviceGroup } from '../entities/user-role-assignment-device-group.entity'; +import { + ReplaceUserRolesDto, + UserRoleAssignmentDto, +} from '../dto/user-role.dto'; +import { + DEVICE_SCOPED_PERMISSION_CODES, + isKnownPermissionCode, + PermissionCode, +} from '../constants/permission-catalog'; +import { RbacAuditService } from './rbac-audit.service'; +import { RbacAuthorizationService } from './rbac-authorization.service'; + +@Injectable() +export class UserRoleService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectRepository(Role) + private readonly roleRepository: Repository, + @InjectRepository(RolePermission) + private readonly rolePermissionRepository: Repository, + @InjectRepository(UserRoleAssignment) + private readonly assignmentRepository: Repository, + @InjectRepository(UserRoleAssignmentDeviceGroup) + private readonly assignmentGroupRepository: Repository, + @InjectRepository(DeviceGroup) + private readonly deviceGroupRepository: Repository, + private readonly dataSource: DataSource, + private readonly auditService: RbacAuditService, + private readonly authorizationService: RbacAuthorizationService, + ) {} + + async getUserRoles(userGuid: string) { + await this.ensureUserExists(userGuid); + const assignments = await this.loadAssignments(userGuid); + return { + data: assignments.map((assignment) => this.toResponse(assignment)), + effective_scope: this.effectiveScopes(assignments), + }; + } + + async replaceUserRoles( + userGuid: string, + dto: ReplaceUserRolesDto, + actorGuid: string, + ) { + await this.authorizationService.requireSuperAdmin(actorGuid); + await this.ensureUserExists(userGuid); + const normalized = this.validateAssignments(dto.assignments); + const roleGuids = normalized.map((assignment) => assignment.role_guid); + const roles = roleGuids.length + ? await this.roleRepository.find({ where: { guid: In(roleGuids) } }) + : []; + if (roles.length !== roleGuids.length) { + const found = new Set(roles.map((role) => role.guid)); + throw new NotFoundException( + `角色不存在: ${roleGuids.filter((guid) => !found.has(guid)).join(', ')}`, + ); + } + const rolePermissions = roles.length + ? await this.rolePermissionRepository.find({ + where: { roleGuid: In(roleGuids) }, + }) + : []; + const permissionsByRole = this.groupPermissions(rolePermissions); + for (const assignment of normalized) { + const permissions = permissionsByRole.get(assignment.role_guid) || []; + if ( + assignment.scope_type === 'device_group' && + permissions.some( + (permission) => !DEVICE_SCOPED_PERMISSION_CODES.has(permission), + ) + ) { + throw new BadRequestException( + 'device_group scope only supports device actions and strategies.assign', + ); + } + } + const groupGuids = [ + ...new Set( + normalized.flatMap((assignment) => assignment.device_group_guids || []), + ), + ]; + if (groupGuids.length) { + const groups = await this.deviceGroupRepository.find({ + where: { guid: In(groupGuids) }, + select: ['guid'], + }); + if (groups.length !== groupGuids.length) { + const found = new Set(groups.map((group) => group.guid)); + throw new NotFoundException( + `设备组不存在: ${groupGuids.filter((guid) => !found.has(guid)).join(', ')}`, + ); + } + } + const before = await this.getUserRoles(userGuid); + await this.dataSource.transaction(async (manager) => { + const current = await manager.getRepository(UserRoleAssignment).find({ + where: { userGuid }, + select: ['guid'], + }); + if (current.length) { + await manager.delete(UserRoleAssignmentDeviceGroup, { + assignmentGuid: In(current.map((assignment) => assignment.guid)), + }); + } + await manager.delete(UserRoleAssignment, { userGuid }); + for (const assignment of normalized) { + const saved = await manager.getRepository(UserRoleAssignment).save({ + guid: uuidv4(), + userGuid, + roleGuid: assignment.role_guid, + scopeType: assignment.scope_type, + }); + if (assignment.scope_type === 'device_group') { + await manager.getRepository(UserRoleAssignmentDeviceGroup).insert( + (assignment.device_group_guids || []).map((deviceGroupGuid) => ({ + assignmentGuid: saved.guid, + deviceGroupGuid, + })), + ); + } + } + await this.auditService.record( + { + actorUserGuid: actorGuid, + targetType: 'user', + targetGuid: userGuid, + action: 'user_role.replace', + result: 'allowed', + beforeState: before, + afterState: normalized, + }, + manager, + ); + }); + return this.getUserRoles(userGuid); + } + + async revokeUserRole(userGuid: string, roleGuid: string, actorGuid: string) { + await this.authorizationService.requireSuperAdmin(actorGuid); + await this.ensureUserExists(userGuid); + const assignment = await this.assignmentRepository.findOne({ + where: { userGuid, roleGuid }, + }); + if (!assignment) throw new NotFoundException('用户角色分配不存在'); + await this.dataSource.transaction(async (manager) => { + await manager.delete(UserRoleAssignmentDeviceGroup, { + assignmentGuid: assignment.guid, + }); + await manager.delete(UserRoleAssignment, { guid: assignment.guid }); + await this.auditService.record( + { + actorUserGuid: actorGuid, + targetType: 'user', + targetGuid: userGuid, + action: 'user_role.revoke', + result: 'allowed', + beforeState: { + role_guid: roleGuid, + scope_type: assignment.scopeType, + }, + }, + manager, + ); + }); + return { message: '用户角色已撤销' }; + } + + private async loadAssignments(userGuid: string) { + const assignments = await this.assignmentRepository.find({ + where: { userGuid }, + order: { createdAt: 'ASC' }, + }); + if (!assignments.length) + return assignments.map((assignment) => ({ + ...assignment, + permissions: [], + groupGuids: [], + })); + const roleGuids = [ + ...new Set(assignments.map((assignment) => assignment.roleGuid)), + ]; + const assignmentGuids = assignments.map((assignment) => assignment.guid); + const [roles, permissions, groups] = await Promise.all([ + this.roleRepository.find({ where: { guid: In(roleGuids) } }), + this.rolePermissionRepository.find({ + where: { roleGuid: In(roleGuids) }, + }), + this.assignmentGroupRepository.find({ + where: { assignmentGuid: In(assignmentGuids) }, + }), + ]); + const roleMap = new Map(roles.map((role) => [role.guid, role])); + const permissionMap = this.groupPermissions(permissions); + const groupMap = new Map(); + for (const group of groups) { + const values = groupMap.get(group.assignmentGuid) || []; + values.push(group.deviceGroupGuid); + groupMap.set(group.assignmentGuid, values); + } + return assignments.map((assignment) => { + const rolePermissions = permissionMap.get(assignment.roleGuid) || []; + return { + ...assignment, + role: roleMap.get(assignment.roleGuid), + permissions: + assignment.scopeType === 'device_group' + ? rolePermissions.filter((permission) => + DEVICE_SCOPED_PERMISSION_CODES.has(permission), + ) + : rolePermissions, + groupGuids: + assignment.scopeType === 'device_group' + ? (groupMap.get(assignment.guid) || []).sort() + : [], + }; + }); + } + + private effectiveScopes( + assignments: Awaited>, + ) { + const scopes: Record< + string, + { scope_type: 'global' | 'device_group'; device_group_guids: string[] } + > = {}; + for (const assignment of assignments) { + for (const permission of assignment.permissions) { + if (!isKnownPermissionCode(permission)) continue; + const existing = scopes[permission]; + if (existing?.scope_type === 'global') continue; + if (!existing || assignment.scopeType === 'global') { + scopes[permission] = + assignment.scopeType === 'global' + ? { scope_type: 'global', device_group_guids: [] } + : { + scope_type: 'device_group', + device_group_guids: [...assignment.groupGuids], + }; + continue; + } + scopes[permission] = { + scope_type: 'device_group', + device_group_guids: [ + ...new Set([ + ...existing.device_group_guids, + ...assignment.groupGuids, + ]), + ].sort(), + }; + } + } + return scopes; + } + + private toResponse( + assignment: Awaited>[number], + ) { + return { + guid: assignment.guid, + role_guid: assignment.roleGuid, + role_name: assignment.role?.name || '', + scope_type: assignment.scopeType, + device_group_guids: assignment.groupGuids, + permissions: assignment.permissions, + created_at: assignment.createdAt, + updated_at: assignment.updatedAt, + }; + } + + private validateAssignments(assignments: UserRoleAssignmentDto[]) { + const seen = new Set(); + return assignments.map((assignment) => { + if (seen.has(assignment.role_guid)) { + throw new BadRequestException('同一用户不能重复分配角色'); + } + seen.add(assignment.role_guid); + const groups = [...new Set(assignment.device_group_guids || [])]; + if (assignment.scope_type === 'device_group' && groups.length === 0) { + throw new BadRequestException( + 'device_group scope requires at least one device group', + ); + } + if (assignment.scope_type === 'global' && groups.length > 0) { + throw new BadRequestException( + 'global scope cannot include device groups', + ); + } + return { + role_guid: assignment.role_guid, + scope_type: assignment.scope_type, + device_group_guids: + assignment.scope_type === 'device_group' ? groups : [], + }; + }); + } + + private groupPermissions(rows: RolePermission[]) { + const result = new Map(); + for (const row of rows) { + // Damaged/legacy rows must never appear as effective permissions or be + // echoed back as if they were part of the code-owned catalog. + if (!isKnownPermissionCode(row.permissionCode)) continue; + const list = result.get(row.roleGuid) || []; + list.push(row.permissionCode); + result.set(row.roleGuid, list); + } + return result; + } + + private async ensureUserExists(userGuid: string) { + if (!(await this.userRepository.exist({ where: { guid: userGuid } }))) { + throw new NotFoundException('用户不存在'); + } + } +} diff --git a/src/modules/rbac/user-role.controller.ts b/src/modules/rbac/user-role.controller.ts new file mode 100644 index 0000000..208e5f3 --- /dev/null +++ b/src/modules/rbac/user-role.controller.ts @@ -0,0 +1,46 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Put, +} from '@nestjs/common'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireSuperAdmin } from './decorators/require-permission.decorator'; +import { ReplaceUserRolesDto } from './dto/user-role.dto'; +import { UserRoleService } from './services/user-role.service'; + +@Controller('users') +@RequireSuperAdmin() +export class UserRoleController { + constructor(private readonly userRoleService: UserRoleService) {} + + @Get(':guid/roles') + getRoles(@Param('guid', new ParseUUIDPipe({ version: '4' })) guid: string) { + return this.userRoleService.getUserRoles(guid); + } + + @Put(':guid/roles') + @HttpCode(HttpStatus.OK) + replaceRoles( + @Param('guid', new ParseUUIDPipe({ version: '4' })) guid: string, + @Body() dto: ReplaceUserRolesDto, + @CurrentUser('id') actorGuid: string, + ) { + return this.userRoleService.replaceUserRoles(guid, dto, actorGuid); + } + + @Delete(':guid/roles/:roleGuid') + @HttpCode(HttpStatus.OK) + async revokeRole( + @Param('guid', new ParseUUIDPipe({ version: '4' })) guid: string, + @Param('roleGuid', new ParseUUIDPipe({ version: '4' })) roleGuid: string, + @CurrentUser('id') actorGuid: string, + ) { + return this.userRoleService.revokeUserRole(guid, roleGuid, actorGuid); + } +} diff --git a/src/modules/settings/services/general-settings.service.ts b/src/modules/settings/services/general-settings.service.ts index 36639c1..67ae4b2 100644 --- a/src/modules/settings/services/general-settings.service.ts +++ b/src/modules/settings/services/general-settings.service.ts @@ -153,8 +153,7 @@ export class GeneralSettingsService { watermarkEnabled: dto.watermarkEnabled, defaultLanguage: dto.defaultLanguage ?? current.defaultLanguage, jwtExpiryDays: dto.jwtExpiryDays ?? current.jwtExpiryDays, - auditRetentionDays: - dto.auditRetentionDays ?? current.auditRetentionDays, + auditRetentionDays: dto.auditRetentionDays ?? current.auditRetentionDays, site: { frontendUrl: dto.site?.frontendUrl ?? current.site.frontendUrl, backendUrl: dto.site?.backendUrl ?? current.site.backendUrl, diff --git a/src/modules/settings/settings.module.ts b/src/modules/settings/settings.module.ts index b32907c..907c6d4 100644 --- a/src/modules/settings/settings.module.ts +++ b/src/modules/settings/settings.module.ts @@ -6,6 +6,7 @@ import { SmtpSettingsService } from './services/smtp-settings.service'; import { GeneralSettingsController } from './general-settings.controller'; import { FrontendSettingsController } from './frontend-settings.controller'; import { GeneralSettingsService } from './services/general-settings.service'; +import { AdminGuard } from '../../common/guards/admin.guard'; /** * 系统设置模块 @@ -24,7 +25,7 @@ import { GeneralSettingsService } from './services/general-settings.service'; GeneralSettingsController, FrontendSettingsController, ], - providers: [SmtpSettingsService, GeneralSettingsService], + providers: [SmtpSettingsService, GeneralSettingsService, AdminGuard], exports: [SmtpSettingsService, GeneralSettingsService], }) export class SettingsModule {} diff --git a/src/modules/strategy/dto/strategy.dto.ts b/src/modules/strategy/dto/strategy.dto.ts index 14591b0..42a6948 100644 --- a/src/modules/strategy/dto/strategy.dto.ts +++ b/src/modules/strategy/dto/strategy.dto.ts @@ -8,6 +8,7 @@ import { IsInt, IsArray, ArrayMaxSize, + ArrayMinSize, IsIn, } from 'class-validator'; import { Type } from 'class-transformer'; @@ -42,12 +43,13 @@ export class UpdateStrategyDto { export class AssignStrategyDto { @IsString() - @IsNotEmpty() + @IsIn(['device', 'user', 'device_group']) target_type: 'device' | 'user' | 'device_group'; @IsArray() - @IsNotEmpty() + @ArrayMinSize(1) @ArrayMaxSize(200) + @IsString({ each: true }) target_guids: string[]; } diff --git a/src/modules/strategy/strategy.controller.ts b/src/modules/strategy/strategy.controller.ts index 45313e5..8486a29 100644 --- a/src/modules/strategy/strategy.controller.ts +++ b/src/modules/strategy/strategy.controller.ts @@ -7,7 +7,6 @@ import { Body, Param, Query, - UseGuards, HttpCode, HttpStatus, } from '@nestjs/common'; @@ -19,42 +18,44 @@ import { StrategyQueryDto, AssignmentQueryDto, } from './dto/strategy.dto'; -import { AdminGuard } from '../../common/guards/admin.guard'; +import { RequirePermission } from '../rbac/decorators/require-permission.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; @Controller() export class StrategyController { constructor(private readonly strategyService: StrategyService) {} @Get('strategies') - @UseGuards(AdminGuard) + @RequirePermission('strategies.view') async getStrategies(@Query() query: StrategyQueryDto) { return this.strategyService.getStrategies(query); } @Get('strategies/:guid') - @UseGuards(AdminGuard) + @RequirePermission('strategies.view') async getStrategy(@Param('guid') guid: string) { return this.strategyService.getStrategy(guid); } @Get('strategies/:guid/assignments') - @UseGuards(AdminGuard) + @RequirePermission('strategies.assign') async getStrategyAssignments( @Param('guid') guid: string, @Query() query: AssignmentQueryDto, + @CurrentUser('id') userId: string, ) { - return this.strategyService.getStrategyAssignments(guid, query); + return this.strategyService.getStrategyAssignments(guid, query, userId); } @Post('strategies') - @UseGuards(AdminGuard) + @RequirePermission('strategies.create') @HttpCode(HttpStatus.OK) async createStrategy(@Body() dto: CreateStrategyDto) { return this.strategyService.createStrategy(dto); } @Patch('strategies/:guid') - @UseGuards(AdminGuard) + @RequirePermission('strategies.edit') @HttpCode(HttpStatus.OK) async updateStrategy( @Param('guid') guid: string, @@ -64,7 +65,7 @@ export class StrategyController { } @Delete('strategies/:guid') - @UseGuards(AdminGuard) + @RequirePermission('strategies.delete') @HttpCode(HttpStatus.OK) async deleteStrategy(@Param('guid') guid: string) { await this.strategyService.deleteStrategy(guid); @@ -72,26 +73,34 @@ export class StrategyController { } @Post('strategies/:guid/assign') - @UseGuards(AdminGuard) + @RequirePermission('strategies.assign') @HttpCode(HttpStatus.OK) async assignStrategy( @Param('guid') guid: string, @Body() dto: AssignStrategyDto, + @CurrentUser('id') userId: string, ) { return this.strategyService.assignStrategy( guid, dto.target_type, dto.target_guids, + userId, ); } @Post('strategies/:guid/unassign') - @UseGuards(AdminGuard) + @RequirePermission('strategies.assign') @HttpCode(HttpStatus.OK) - async unassignStrategy(@Body() dto: AssignStrategyDto) { + async unassignStrategy( + @Param('guid') strategyGuid: string, + @Body() dto: AssignStrategyDto, + @CurrentUser('id') userId: string, + ) { return this.strategyService.unassignStrategy( + strategyGuid, dto.target_type, dto.target_guids, + userId, ); } } diff --git a/src/modules/strategy/strategy.module.ts b/src/modules/strategy/strategy.module.ts index 313ff18..9143ba1 100644 --- a/src/modules/strategy/strategy.module.ts +++ b/src/modules/strategy/strategy.module.ts @@ -6,9 +6,13 @@ import { Strategy } from './entities/strategy.entity'; import { Peer } from '../../common/entities/peer.entity'; import { User } from '../user/entities/user.entity'; import { DeviceGroup } from '../device-group/entities/device-group.entity'; +import { RbacModule } from '../rbac/rbac.module'; @Module({ - imports: [TypeOrmModule.forFeature([Strategy, Peer, User, DeviceGroup])], + imports: [ + TypeOrmModule.forFeature([Strategy, Peer, User, DeviceGroup]), + RbacModule, + ], controllers: [StrategyController], providers: [StrategyService], exports: [StrategyService], diff --git a/src/modules/strategy/strategy.service.ts b/src/modules/strategy/strategy.service.ts index 045c247..df3536e 100644 --- a/src/modules/strategy/strategy.service.ts +++ b/src/modules/strategy/strategy.service.ts @@ -17,6 +17,7 @@ import { StrategyQueryDto, AssignmentQueryDto, } from './dto/strategy.dto'; +import { RbacAuthorizationService } from '../rbac/services/rbac-authorization.service'; @Injectable() export class StrategyService { @@ -31,6 +32,7 @@ export class StrategyService { private userRepository: Repository, @InjectRepository(DeviceGroup) private deviceGroupRepository: Repository, + private readonly rbacAuthorizationService: RbacAuthorizationService, ) {} async createStrategy(dto: CreateStrategyDto) { @@ -165,7 +167,13 @@ export class StrategyService { strategyGuid: string, targetType: string, targetGuids: string[], + actorGuid: string, ) { + await this.rbacAuthorizationService.assertStrategyTargets( + actorGuid, + targetType as 'device' | 'user' | 'device_group', + targetGuids, + ); const strategy = await this.strategyRepository.findOne({ where: { guid: strategyGuid }, }); @@ -243,24 +251,47 @@ export class StrategyService { return { success, errors }; } - async unassignStrategy(targetType: string, targetGuids: string[]) { + async unassignStrategy( + strategyGuid: string, + targetType: string, + targetGuids: string[], + actorGuid: string, + ) { + await this.rbacAuthorizationService.assertStrategyTargets( + actorGuid, + targetType as 'device' | 'user' | 'device_group', + targetGuids, + ); const success: string[] = []; const errors: { target_guid: string; reason: string }[] = []; switch (targetType) { case 'device': { const peers = await this.peerRepository.find({ - where: { uuid: In(targetGuids) }, + where: { uuid: In(targetGuids), strategyGuid }, }); const foundUuids = new Set(peers.map((p) => p.uuid)); + const allPeers = + targetGuids.length > 0 + ? await this.peerRepository.find({ + where: { uuid: In(targetGuids) }, + select: ['uuid'], + }) + : []; + const existingUuids = new Set(allPeers.map((p) => p.uuid)); for (const targetGuid of targetGuids) { - if (!foundUuids.has(targetGuid)) { + if (!existingUuids.has(targetGuid)) { errors.push({ target_guid: targetGuid, reason: '设备不存在' }); + } else if (!foundUuids.has(targetGuid)) { + errors.push({ + target_guid: targetGuid, + reason: '设备未绑定该策略', + }); } } if (peers.length > 0) { await this.peerRepository.update( - { uuid: In(peers.map((p) => p.uuid)) }, + { uuid: In(peers.map((p) => p.uuid)), strategyGuid }, { strategyGuid: null }, ); success.push(...peers.map((p) => p.uuid)); @@ -269,17 +300,30 @@ export class StrategyService { } case 'user': { const users = await this.userRepository.find({ - where: { guid: In(targetGuids) }, + where: { guid: In(targetGuids), strategyGuid }, }); const foundGuids = new Set(users.map((u) => u.guid)); + const allUsers = + targetGuids.length > 0 + ? await this.userRepository.find({ + where: { guid: In(targetGuids) }, + select: ['guid'], + }) + : []; + const existingGuids = new Set(allUsers.map((u) => u.guid)); for (const targetGuid of targetGuids) { - if (!foundGuids.has(targetGuid)) { + if (!existingGuids.has(targetGuid)) { errors.push({ target_guid: targetGuid, reason: '用户不存在' }); + } else if (!foundGuids.has(targetGuid)) { + errors.push({ + target_guid: targetGuid, + reason: '用户未绑定该策略', + }); } } if (users.length > 0) { await this.userRepository.update( - { guid: In(users.map((u) => u.guid)) }, + { guid: In(users.map((u) => u.guid)), strategyGuid }, { strategyGuid: null }, ); success.push(...users.map((u) => u.guid)); @@ -288,17 +332,30 @@ export class StrategyService { } case 'device_group': { const groups = await this.deviceGroupRepository.find({ - where: { guid: In(targetGuids) }, + where: { guid: In(targetGuids), strategyGuid }, }); const foundGuids = new Set(groups.map((g) => g.guid)); + const allGroups = + targetGuids.length > 0 + ? await this.deviceGroupRepository.find({ + where: { guid: In(targetGuids) }, + select: ['guid'], + }) + : []; + const existingGuids = new Set(allGroups.map((g) => g.guid)); for (const targetGuid of targetGuids) { - if (!foundGuids.has(targetGuid)) { + if (!existingGuids.has(targetGuid)) { errors.push({ target_guid: targetGuid, reason: '设备组不存在' }); + } else if (!foundGuids.has(targetGuid)) { + errors.push({ + target_guid: targetGuid, + reason: '设备组未绑定该策略', + }); } } if (groups.length > 0) { await this.deviceGroupRepository.update( - { guid: In(groups.map((g) => g.guid)) }, + { guid: In(groups.map((g) => g.guid)), strategyGuid }, { strategyGuid: null }, ); success.push(...groups.map((g) => g.guid)); @@ -314,7 +371,11 @@ export class StrategyService { return { success, errors }; } - async getStrategyAssignments(guid: string, query: AssignmentQueryDto) { + async getStrategyAssignments( + guid: string, + query: AssignmentQueryDto, + actorGuid: string, + ) { const strategy = await this.strategyRepository.findOne({ where: { guid }, }); @@ -324,16 +385,30 @@ export class StrategyService { const { target_type, current, pageSize } = query; const skip = (current - 1) * pageSize; + const scope = await this.rbacAuthorizationService.requirePermission( + actorGuid, + 'strategies.assign', + ); switch (target_type) { case 'device': { - const [peers, total] = await this.peerRepository.findAndCount({ - where: { strategyGuid: guid }, - select: ['uuid', 'id', 'status'], - skip, - take: pageSize, - order: { id: 'ASC' }, - }); + let queryBuilder = this.peerRepository + .createQueryBuilder('peer') + .where('peer.strategyGuid = :strategyGuid', { strategyGuid: guid }) + .select(['peer.uuid', 'peer.id', 'peer.status']); + if (!scope.global) { + queryBuilder = scope.deviceGroupGuids.size + ? queryBuilder.andWhere( + 'peer.deviceGroupGuid IN (:...deviceGroupGuids)', + { deviceGroupGuids: [...scope.deviceGroupGuids] }, + ) + : queryBuilder.andWhere('1 = 0'); + } + const [peers, total] = await queryBuilder + .orderBy('peer.id', 'ASC') + .skip(skip) + .take(pageSize) + .getManyAndCount(); return { data: peers.map((p) => ({ uuid: p.uuid, @@ -344,6 +419,11 @@ export class StrategyService { }; } case 'user': { + await this.rbacAuthorizationService.assertStrategyTargets( + actorGuid, + 'user', + [], + ); const [users, total] = await this.userRepository.findAndCount({ where: { strategyGuid: guid }, select: ['guid', 'username', 'email', 'status', 'isAdmin'], @@ -363,13 +443,25 @@ export class StrategyService { }; } case 'device_group': { - const [groups, total] = await this.deviceGroupRepository.findAndCount({ - where: { strategyGuid: guid }, - select: ['guid', 'name', 'note'], - skip, - take: pageSize, - order: { name: 'ASC' }, - }); + let queryBuilder = this.deviceGroupRepository + .createQueryBuilder('deviceGroup') + .where('deviceGroup.strategyGuid = :strategyGuid', { + strategyGuid: guid, + }) + .select(['deviceGroup.guid', 'deviceGroup.name', 'deviceGroup.note']); + if (!scope.global) { + queryBuilder = scope.deviceGroupGuids.size + ? queryBuilder.andWhere( + 'deviceGroup.guid IN (:...deviceGroupGuids)', + { deviceGroupGuids: [...scope.deviceGroupGuids] }, + ) + : queryBuilder.andWhere('1 = 0'); + } + const [groups, total] = await queryBuilder + .orderBy('deviceGroup.name', 'ASC') + .skip(skip) + .take(pageSize) + .getManyAndCount(); return { data: groups.map((g) => ({ guid: g.guid, diff --git a/src/modules/update-check/update-check.module.ts b/src/modules/update-check/update-check.module.ts index 58ea4b0..52d3c2e 100644 --- a/src/modules/update-check/update-check.module.ts +++ b/src/modules/update-check/update-check.module.ts @@ -23,6 +23,7 @@ import { NexusToken } from '../nexus/entities/nexus-token.entity'; import { ActiveConnection } from '../heartbeat/entities/active-connection.entity'; import { UpdateCheckController } from './update-check.controller'; import { UpdateCheckService } from './update-check.service'; +import { AdminGuard } from '../../common/guards/admin.guard'; /** * 更新检查模块 @@ -55,7 +56,7 @@ import { UpdateCheckService } from './update-check.service'; ]), ], controllers: [UpdateCheckController], - providers: [UpdateCheckService], + providers: [UpdateCheckService, AdminGuard], exports: [UpdateCheckService], }) export class UpdateCheckModule {} diff --git a/src/modules/user-group/user-group.controller.ts b/src/modules/user-group/user-group.controller.ts index 05e4453..d2c1d94 100644 --- a/src/modules/user-group/user-group.controller.ts +++ b/src/modules/user-group/user-group.controller.ts @@ -10,9 +10,9 @@ import { Post, Put, Query, - UseGuards, } from '@nestjs/common'; -import { AdminGuard } from '../../common/guards/admin.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequirePermission } from '../rbac/decorators/require-permission.decorator'; import { CreateUserGroupDto, UpdateUserGroupDto, @@ -22,22 +22,24 @@ import { import { UserGroupService } from './user-group.service'; @Controller('user-groups') -@UseGuards(AdminGuard) export class UserGroupController { constructor(private readonly userGroupService: UserGroupService) {} @Get() + @RequirePermission('user_groups.view') getGroups(@Query() query: UserGroupQueryDto) { return this.userGroupService.getGroups(query); } @Post() + @RequirePermission('user_groups.create') @HttpCode(HttpStatus.OK) createGroup(@Body() dto: CreateUserGroupDto) { return this.userGroupService.createGroup(dto); } @Put(':guid') + @RequirePermission('user_groups.edit') @HttpCode(HttpStatus.OK) updateGroup( @Param('guid', new ParseUUIDPipe({ version: '4' })) guid: string, @@ -47,14 +49,17 @@ export class UserGroupController { } @Delete(':guid') + @RequirePermission('user_groups.delete') @HttpCode(HttpStatus.OK) deleteGroup( @Param('guid', new ParseUUIDPipe({ version: '4' })) guid: string, + @CurrentUser('id') actorGuid: string, ) { - return this.userGroupService.deleteGroup(guid); + return this.userGroupService.deleteGroup(guid, actorGuid); } @Get(':guid/users') + @RequirePermission('user_groups.membership') getGroupUsers( @Param('guid', new ParseUUIDPipe({ version: '4' })) guid: string, @Query() query: UserGroupQueryDto, @@ -63,11 +68,13 @@ export class UserGroupController { } @Post(':guid/users') + @RequirePermission('user_groups.membership') @HttpCode(HttpStatus.OK) moveUsers( @Param('guid', new ParseUUIDPipe({ version: '4' })) guid: string, @Body() dto: UserGroupMembersDto, + @CurrentUser('id') actorGuid: string, ) { - return this.userGroupService.moveUsers(guid, dto.user_guids); + return this.userGroupService.moveUsers(guid, dto.user_guids, actorGuid); } } diff --git a/src/modules/user-group/user-group.integration.spec.ts b/src/modules/user-group/user-group.integration.spec.ts index a98180e..cfe1f91 100644 --- a/src/modules/user-group/user-group.integration.spec.ts +++ b/src/modules/user-group/user-group.integration.spec.ts @@ -13,8 +13,8 @@ import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { DataSource, Repository } from 'typeorm'; import request from 'supertest'; -import { AdminGuard } from '../../common/guards/admin.guard'; import { DatabaseInitService } from '../../database/database-init.service'; +import { AdminGuard } from '../../common/guards/admin.guard'; import { AddressBookPeerTag } from '../address-book/entities/address-book-peer-tag.entity'; import { AddressBookPeer } from '../address-book/entities/address-book-peer.entity'; import { @@ -45,6 +45,8 @@ import { UserGroupMembersDto, UserGroupQueryDto } from './dto/user-group.dto'; import { UserGroup } from './entities/user-group.entity'; import { UserGroupController } from './user-group.controller'; import { UserGroupService } from './user-group.service'; +import { REQUIRE_PERMISSION_KEY } from '../rbac/decorators/require-permission.decorator'; +import { RbacAuthorizationService } from '../rbac/services/rbac-authorization.service'; interface UserGroupHttpBody { guid: string; @@ -75,6 +77,7 @@ describe('User group integration', () => { let permissionService: AddressBookPermissionService; let ruleService: AddressBookRuleService; let userService: UserService; + let authorizationService: { assertUsersMutation: jest.Mock }; beforeEach(async () => { dataSource = new DataSource({ @@ -101,12 +104,16 @@ describe('User group integration', () => { userRepository = dataSource.getRepository(User); ruleRepository = dataSource.getRepository(AddressBookRule); addressBookRepository = dataSource.getRepository(AddressBook); + authorizationService = { + assertUsersMutation: jest.fn().mockResolvedValue(undefined), + }; userGroupService = new UserGroupService( groupRepository, userRepository, ruleRepository, dataSource, + authorizationService as unknown as RbacAuthorizationService, ); permissionService = new AddressBookPermissionService( addressBookRepository, @@ -268,7 +275,11 @@ describe('User group integration', () => { const alice = await createUser('alice', defaultGroup.guid); const bob = await createUser('bob', defaultGroup.guid); await expect( - userGroupService.moveUsers(operations.guid, [alice.guid, randomUUID()]), + userGroupService.moveUsers( + operations.guid, + [alice.guid, randomUUID()], + 'actor', + ), ).rejects.toThrow('一个或多个用户不存在'); expect( (await userRepository.findOneByOrFail({ guid: alice.guid })) @@ -276,7 +287,11 @@ describe('User group integration', () => { ).toBe(defaultGroup.guid); await expect( - userGroupService.moveUsers(operations.guid, [alice.guid, bob.guid]), + userGroupService.moveUsers( + operations.guid, + [alice.guid, bob.guid], + 'actor', + ), ).resolves.toMatchObject({ moved_user_count: 2 }); const groups = await userGroupService.getGroups({ @@ -468,7 +483,7 @@ describe('User group integration', () => { ); await expect( - userGroupService.deleteGroup(temporaryGroup.guid), + userGroupService.deleteGroup(temporaryGroup.guid, 'actor'), ).resolves.toEqual({ message: '用户组删除成功', moved_user_count: 1, @@ -487,10 +502,61 @@ describe('User group integration', () => { }), ).toBe(0); await expect( - userGroupService.deleteGroup(defaultGroup.guid), + userGroupService.deleteGroup(defaultGroup.guid, 'actor'), ).rejects.toThrow('默认用户组不能删除'); }); + it('protects administrator members on move and group deletion paths', async () => { + const defaultGroup = await userGroupService.initializeStorage(); + const protectedGroup = await userGroupService.createGroup({ + name: 'Protected administrators', + }); + const administrator = await createUser( + 'protected-administrator', + protectedGroup.guid, + ); + administrator.isAdmin = true; + await userRepository.save(administrator); + + authorizationService.assertUsersMutation.mockRejectedValueOnce( + new ForbiddenException('不能修改超级管理员'), + ); + await expect( + userGroupService.moveUsers( + defaultGroup.guid, + [administrator.guid], + 'actor', + ), + ).rejects.toBeInstanceOf(ForbiddenException); + expect( + (await userRepository.findOneByOrFail({ guid: administrator.guid })) + .userGroupGuid, + ).toBe(protectedGroup.guid); + + authorizationService.assertUsersMutation.mockRejectedValueOnce( + new ForbiddenException('不能修改超级管理员'), + ); + await expect( + userGroupService.deleteGroup(protectedGroup.guid, 'actor'), + ).rejects.toBeInstanceOf(ForbiddenException); + expect( + await groupRepository.findOneBy({ guid: protectedGroup.guid }), + ).not.toBeNull(); + + expect(authorizationService.assertUsersMutation).toHaveBeenNthCalledWith( + 1, + 'actor', + [administrator.guid], + 'user_groups.membership', + ); + expect(authorizationService.assertUsersMutation).toHaveBeenNthCalledWith( + 2, + 'actor', + [administrator.guid], + 'user_groups.delete', + ); + }); + it('rolls back member and rule changes when group deletion fails', async () => { const defaultGroup = await userGroupService.initializeStorage(); const protectedGroup = await userGroupService.createGroup({ @@ -515,7 +581,7 @@ describe('User group integration', () => { ); await expect( - userGroupService.deleteGroup(protectedGroup.guid), + userGroupService.deleteGroup(protectedGroup.guid, 'actor'), ).rejects.toThrow('forced delete failure'); expect( (await userRepository.findOneByOrFail({ guid: member.guid })) @@ -574,7 +640,7 @@ describe('User group integration', () => { ), ).rejects.toBeInstanceOf(ForbiddenException); - await userGroupService.moveUsers(guests.guid, [member.guid]); + await userGroupService.moveUsers(guests.guid, [member.guid], 'actor'); await expect( permissionService.checkAddressBookAccess( addressBook.guid, @@ -654,7 +720,7 @@ describe('User group integration', () => { data: [{ guid: addressBook.guid, rule: ShareRule.READ }], }); - await userGroupService.moveUsers(guests.guid, [member.guid]); + await userGroupService.moveUsers(guests.guid, [member.guid], 'actor'); const movedMemberBooks = await ruleService.getSharedAddressBooks( member.guid, { current: 1, pageSize: 20 }, @@ -748,13 +814,7 @@ describe('User group integration', () => { ).toBeNull(); }); - it('publishes validated DTOs and protects every user-group route with AdminGuard', async () => { - const guards = Reflect.getMetadata( - GUARDS_METADATA, - UserGroupController, - ) as unknown[]; - expect(guards).toContain(AdminGuard); - + it('publishes validated DTOs and declares permissions on every admin route', async () => { const validLegacyCreate = plainToInstance(CreateUserDto, { name: 'new-user', password: 'test-password', @@ -799,20 +859,41 @@ describe('User group integration', () => { }); expect(await validate(invalidAddressBookDelete)).not.toHaveLength(0); - for (const methodName of [ - 'addSharedAddressBook', - 'updateSharedAddressBook', - 'deleteSharedAddressBooks', - 'addRule', - 'updateRule', - 'deleteRules', - ] as const) { - const method = AddressBookController.prototype[methodName]; - const methodGuards = Reflect.getMetadata( - GUARDS_METADATA, - method, - ) as unknown[]; - expect(methodGuards).toContain(AdminGuard); + const expectedPermissions = { + addSharedAddressBook: ['address_books.share'], + updateSharedAddressBook: ['address_books.edit'], + deleteSharedAddressBooks: ['address_books.edit'], + addRule: ['address_books.share'], + updateRule: ['address_books.share'], + deleteRules: ['address_books.share'], + } as const; + for (const [methodName, permissions] of Object.entries( + expectedPermissions, + )) { + expect( + Reflect.getMetadata( + REQUIRE_PERMISSION_KEY, + AddressBookController.prototype[ + methodName as keyof AddressBookController + ], + ), + ).toEqual(permissions); + } + + const ownerSelfServiceMethods = [ + 'getCustomAddressBooks', + 'addCustomAddressBook', + 'updateCustomAddressBook', + 'deleteCustomAddressBooks', + ] as const; + for (const methodName of ownerSelfServiceMethods) { + const handler = AddressBookController.prototype[methodName]; + expect( + Reflect.getMetadata(REQUIRE_PERMISSION_KEY, handler), + ).toBeUndefined(); + expect(Reflect.getMetadata(GUARDS_METADATA, handler) ?? []).not.toContain( + AdminGuard, + ); } }); @@ -820,14 +901,8 @@ describe('User group integration', () => { await userGroupService.initializeStorage(); const moduleRef = await Test.createTestingModule({ controllers: [UserGroupController], - providers: [ - { provide: UserGroupService, useValue: userGroupService }, - AdminGuard, - ], - }) - .overrideGuard(AdminGuard) - .useValue({ canActivate: () => true }) - .compile(); + providers: [{ provide: UserGroupService, useValue: userGroupService }], + }).compile(); const app: INestApplication = moduleRef.createNestApplication(); app.setGlobalPrefix('api'); app.useGlobalPipes( diff --git a/src/modules/user-group/user-group.module.ts b/src/modules/user-group/user-group.module.ts index 10c9cff..a9ac6ab 100644 --- a/src/modules/user-group/user-group.module.ts +++ b/src/modules/user-group/user-group.module.ts @@ -5,9 +5,13 @@ import { User } from '../user/entities/user.entity'; import { UserGroup } from './entities/user-group.entity'; import { UserGroupController } from './user-group.controller'; import { UserGroupService } from './user-group.service'; +import { RbacModule } from '../rbac/rbac.module'; @Module({ - imports: [TypeOrmModule.forFeature([UserGroup, User, AddressBookRule])], + imports: [ + TypeOrmModule.forFeature([UserGroup, User, AddressBookRule]), + RbacModule, + ], controllers: [UserGroupController], providers: [UserGroupService], exports: [UserGroupService], diff --git a/src/modules/user-group/user-group.service.ts b/src/modules/user-group/user-group.service.ts index a1260c8..e3e6e6f 100644 --- a/src/modules/user-group/user-group.service.ts +++ b/src/modules/user-group/user-group.service.ts @@ -16,6 +16,7 @@ import { UserGroupQueryDto, } from './dto/user-group.dto'; import { UserGroup } from './entities/user-group.entity'; +import { RbacAuthorizationService } from '../rbac/services/rbac-authorization.service'; const DEFAULT_USER_GROUP_NAME = 'Default'; @@ -33,6 +34,7 @@ export class UserGroupService { @InjectRepository(AddressBookRule) private readonly ruleRepository: Repository, private readonly dataSource: DataSource, + private readonly authorizationService: RbacAuthorizationService, ) {} async initializeStorage(): Promise { @@ -223,7 +225,18 @@ export class UserGroupService { } } - async deleteGroup(guid: string) { + async deleteGroup(guid: string, actorGuid: string) { + const protectedUsers = await this.userRepository.find({ + where: { userGroupGuid: guid, isAdmin: true }, + select: ['guid'], + }); + if (protectedUsers.length) { + await this.authorizationService.assertUsersMutation( + actorGuid, + protectedUsers.map((user) => user.guid), + 'user_groups.delete', + ); + } return this.dataSource.transaction(async (manager) => { const groupRepository = manager.getRepository(UserGroup); const userRepository = manager.getRepository(User); @@ -298,7 +311,12 @@ export class UserGroupService { }; } - async moveUsers(guid: string, userGuids: string[]) { + async moveUsers(guid: string, userGuids: string[], actorGuid: string) { + await this.authorizationService.assertUsersMutation( + actorGuid, + userGuids, + 'user_groups.membership', + ); const uniqueGuids = [...new Set(userGuids)]; return this.dataSource.transaction(async (manager) => { diff --git a/src/modules/user/admin-user.controller.ts b/src/modules/user/admin-user.controller.ts index f05e499..7f8bea8 100644 --- a/src/modules/user/admin-user.controller.ts +++ b/src/modules/user/admin-user.controller.ts @@ -1,14 +1,14 @@ -import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { Controller, Get, Query } from '@nestjs/common'; import { AdminUserService } from './admin-user.service'; -import { AdminGuard } from '../../common/guards/admin.guard'; import { AdminUserQueryDto } from './dto/admin-user.dto'; +import { RequirePermission } from '../rbac/decorators/require-permission.decorator'; @Controller('admin/users') -@UseGuards(AdminGuard) export class AdminUserController { constructor(private readonly adminUserService: AdminUserService) {} @Get() + @RequirePermission('users.view') async getAdminUsers(@Query() query: AdminUserQueryDto) { return this.adminUserService.getAdminUsers(query); } diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 4f195c8..255ec81 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -7,7 +7,6 @@ import { Body, Param, Query, - UseGuards, HttpCode, HttpStatus, UseInterceptors, @@ -17,9 +16,10 @@ import { import { FileInterceptor } from '@nestjs/platform-express'; import { Throttle } from '@nestjs/throttler'; import { UserService } from './user.service'; -import { AdminGuard } from '../../common/guards/admin.guard'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Public } from '../auth/decorators/public.decorator'; +import { RequirePermission } from '../rbac/decorators/require-permission.decorator'; +import { RbacAuthorizationService } from '../rbac/services/rbac-authorization.service'; import { CreateUserDto, InviteUserDto, @@ -37,21 +37,38 @@ import { @Controller() export class UserController { - constructor(private readonly userService: UserService) {} + constructor( + private readonly userService: UserService, + private readonly rbacAuthorizationService: RbacAuthorizationService, + ) {} @Get('users') async getAccessibleUsers( @CurrentUser('id') userId: string, - @CurrentUser('isAdmin') isAdmin: boolean, @Query() query: UserQueryDto, ) { - return this.userService.getAccessibleUsers(userId, query, isAdmin); + const currentUser = + await this.rbacAuthorizationService.getCurrentUser(userId); + return this.userService.getAccessibleUsers( + userId, + query, + currentUser.isAdmin, + ); } @Post('users') - @UseGuards(AdminGuard) + @RequirePermission('users.create') @HttpCode(HttpStatus.OK) - async createUser(@Body() dto: CreateUserDto) { + async createUser( + @Body() dto: CreateUserDto, + @CurrentUser('id') actorGuid: string, + ) { + if (dto.user_group_guid !== undefined) { + await this.rbacAuthorizationService.requirePermission( + actorGuid, + 'user_groups.membership', + ); + } return this.userService.createUser(dto); } @@ -98,9 +115,18 @@ export class UserController { } @Post('users/invite') - @UseGuards(AdminGuard) + @RequirePermission('users.create') @HttpCode(HttpStatus.OK) - async inviteUser(@Body() dto: InviteUserDto) { + async inviteUser( + @Body() dto: InviteUserDto, + @CurrentUser('id') actorGuid: string, + ) { + if (dto.user_group_guid !== undefined) { + await this.rbacAuthorizationService.requirePermission( + actorGuid, + 'user_groups.membership', + ); + } return this.userService.inviteUser(dto); } @@ -119,62 +145,118 @@ export class UserController { } @Patch('users/batch/status') - @UseGuards(AdminGuard) + @RequirePermission('users.status') @HttpCode(HttpStatus.OK) - async batchUpdateStatus(@Body() dto: BatchStatusDto) { + async batchUpdateStatus( + @Body() dto: BatchStatusDto, + @CurrentUser('id') actorGuid: string, + ) { + await this.rbacAuthorizationService.assertUsersMutation( + actorGuid, + dto.user_guids, + 'users.status', + ); return this.userService.batchUpdateStatus(dto); } @Patch('users/batch/security') - @UseGuards(AdminGuard) + @RequirePermission('users.security') @HttpCode(HttpStatus.OK) - async batchUpdateSecurity(@Body() dto: BatchSecurityDto) { + async batchUpdateSecurity( + @Body() dto: BatchSecurityDto, + @CurrentUser('id') actorGuid: string, + ) { + await this.rbacAuthorizationService.assertUsersMutation( + actorGuid, + dto.user_guids, + 'users.security', + ); return this.userService.batchUpdateSecurity(dto); } @Delete('users/batch/sessions') - @UseGuards(AdminGuard) + @RequirePermission('users.force_logout') @HttpCode(HttpStatus.OK) - async batchDeleteSessions(@Body() dto: BatchSessionsDto) { + async batchDeleteSessions( + @Body() dto: BatchSessionsDto, + @CurrentUser('id') actorGuid: string, + ) { + await this.rbacAuthorizationService.assertUsersMutation( + actorGuid, + dto.user_guids, + 'users.force_logout', + ); return this.userService.forceLogout(dto.user_guids); } @Get('users/:guid') - @UseGuards(AdminGuard) + @RequirePermission('users.view') async getUser(@Param('guid') guid: string) { return this.userService.getUser(guid); } @Patch('users/:guid') - @UseGuards(AdminGuard) + @RequirePermission('users.edit') @HttpCode(HttpStatus.OK) - async updateUser(@Param('guid') guid: string, @Body() dto: UpdateUserDto) { + async updateUser( + @Param('guid') guid: string, + @Body() dto: UpdateUserDto, + @CurrentUser('id') actorGuid: string, + ) { + await this.rbacAuthorizationService.assertUserMutation( + actorGuid, + guid, + 'users.edit', + dto, + ); return this.userService.updateUser(guid, dto); } @Delete('users/:guid') - @UseGuards(AdminGuard) + @RequirePermission('users.delete') @HttpCode(HttpStatus.OK) - async deleteUser(@Param('guid') guid: string) { + async deleteUser( + @Param('guid') guid: string, + @CurrentUser('id') actorGuid: string, + ) { + await this.rbacAuthorizationService.assertUserMutation( + actorGuid, + guid, + 'users.delete', + ); await this.userService.deleteUser(guid); return { message: '用户已删除' }; } @Patch('users/:guid/security') - @UseGuards(AdminGuard) + @RequirePermission('users.security') @HttpCode(HttpStatus.OK) async updateUserSecurity( @Param('guid') guid: string, @Body() dto: UpdateUserSecurityDto, + @CurrentUser('id') actorGuid: string, ) { + await this.rbacAuthorizationService.assertUserMutation( + actorGuid, + guid, + 'users.security', + ); await this.userService.updateUserSecurity(guid, dto); return { message: '安全设置已更新' }; } @Delete('users/:guid/sessions') - @UseGuards(AdminGuard) + @RequirePermission('users.force_logout') @HttpCode(HttpStatus.OK) - async deleteUserSessions(@Param('guid') guid: string) { + async deleteUserSessions( + @Param('guid') guid: string, + @CurrentUser('id') actorGuid: string, + ) { + await this.rbacAuthorizationService.assertUserMutation( + actorGuid, + guid, + 'users.force_logout', + ); return this.userService.forceLogout([guid]); } } diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index 89ea839..419445e 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -17,6 +17,7 @@ import { Strategy } from '../strategy/entities/strategy.entity'; import { UserGroupModule } from '../user-group/user-group.module'; import { EmailModule } from '../email/email.module'; import { SettingsModule } from '../settings/settings.module'; +import { RbacModule } from '../rbac/rbac.module'; @Module({ imports: [ @@ -35,6 +36,7 @@ import { SettingsModule } from '../settings/settings.module'; UserGroupModule, EmailModule, SettingsModule, + RbacModule, ], controllers: [UserController, AvatarController, AdminUserController], providers: [UserService, AdminUserService], diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index 2426b0e..0bc7885 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -392,6 +392,8 @@ export class UserService { if (!user) { throw new NotFoundException('用户不存在'); } + const previousStatus = user.status; + const previousIsAdmin = user.isAdmin; if (dto.name !== undefined) { const existingUser = await this.userRepository.findOne({ @@ -439,6 +441,13 @@ export class UserService { await this.userRepository.save(user); + if ( + (dto.status !== undefined && dto.status !== previousStatus) || + (dto.is_admin !== undefined && dto.is_admin !== previousIsAdmin) + ) { + await this.revokeActiveTokens([guid]); + } + return { message: '用户已更新' }; } @@ -560,6 +569,16 @@ export class UserService { return { message: '强制登出成功' }; } + private async revokeActiveTokens(userGuids: string[]): Promise { + const uniqueGuids = [...new Set(userGuids)]; + if (uniqueGuids.length === 0) return; + + await this.userTokenRepository.update( + { userGuid: In(uniqueGuids), isRevoked: false }, + { isRevoked: true }, + ); + } + async batchUpdateStatus(dto: BatchStatusDto) { const { user_guids, status } = dto; const users = await this.userRepository.find({ @@ -593,6 +612,10 @@ export class UserService { succeeded.push(...guidsToUpdate); } + if (status !== UserStatus.ACTIVE && guidsToUpdate.length > 0) { + await this.revokeActiveTokens(guidsToUpdate); + } + return { succeeded, failed,