From 816c5461f96d20478927617500a3e99e1c7bf144 Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 7 Mar 2026 01:17:27 -0800 Subject: [PATCH] feat(auth): add platform_super_admin role for two-level platform access control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 platform_admin 之上新增 platform_super_admin 角色,实现平台管理员的两级权限体系。 ## 角色层级 platform_super_admin > platform_admin > admin > operator > viewer - platform_super_admin:最高平台权限,含所有 platform_admin 操作 + 破坏性操作(删除租户/用户/版本) - platform_admin:日常平台运营,可查看/编辑租户、管理 App 版本、配置账单套餐,不可执行删除 ## 变更明细 ### auth-service — role-type.vo.ts - 新增 RoleType.PLATFORM_SUPER_ADMIN = 'platform_super_admin' ### auth-service — tenant.controller.ts - 租户列表/创建/查看/编辑:@Roles('platform_admin', 'platform_super_admin')(两级均可) - 删除租户 DELETE /:id:@Roles('platform_super_admin')(仅超管) ### auth-service — user.controller.ts - 类级别:@Roles('platform_admin', 'platform_super_admin')(两级均可访问用户列表/创建/编辑) - 删除用户 DELETE /:id:@Roles('platform_super_admin')(仅超管) ### version-service — guards/platform-admin.guard.ts - 更新:接受 platform_admin 或 platform_super_admin 任一角色 - 重构:抽取 decodeJwtRoles() 工具函数,供 PlatformSuperAdminGuard 复用 ### version-service — guards/platform-super-admin.guard.ts(新文件) - 仅接受 platform_super_admin 角色 - 与 PlatformAdminGuard(类级别)叠加使用,实现方法级别的超管限制 ### version-service — version.controller.ts - DELETE /:id:叠加 @UseGuards(PlatformSuperAdminGuard)(仅超管可删除版本文件) ### web-admin — sidebar.tsx - isPlatformAdmin 检测同时涵盖 platform_admin 和 platform_super_admin - 两级平台管理员均显示相同侧边栏菜单 ## 升级现有账号为 platform_super_admin UPDATE public.users SET roles = '{platform_super_admin}' WHERE email = 'xxx@xxx.com'; Co-Authored-By: Claude Sonnet 4.6 --- .../components/layout/sidebar.tsx | 5 +- .../src/domain/value-objects/role-type.vo.ts | 4 +- .../rest/controllers/tenant.controller.ts | 12 ++--- .../rest/controllers/user.controller.ts | 3 +- .../src/guards/platform-admin.guard.ts | 52 +++++++++---------- .../src/guards/platform-super-admin.guard.ts | 36 +++++++++++++ .../rest/controllers/version.controller.ts | 2 + 7 files changed, 79 insertions(+), 35 deletions(-) create mode 100644 packages/services/version-service/src/guards/platform-super-admin.guard.ts diff --git a/it0-web-admin/src/presentation/components/layout/sidebar.tsx b/it0-web-admin/src/presentation/components/layout/sidebar.tsx index 732a021..d8af725 100644 --- a/it0-web-admin/src/presentation/components/layout/sidebar.tsx +++ b/it0-web-admin/src/presentation/components/layout/sidebar.tsx @@ -95,7 +95,10 @@ export function Sidebar() { const raw = localStorage.getItem('user'); if (raw) { const user = JSON.parse(raw) as { roles?: string[] }; - setIsPlatformAdmin(Array.isArray(user.roles) && user.roles.includes('platform_admin')); + setIsPlatformAdmin( + Array.isArray(user.roles) && + (user.roles.includes('platform_admin') || user.roles.includes('platform_super_admin')), + ); } } catch { /* ignore */ } }, []); diff --git a/packages/services/auth-service/src/domain/value-objects/role-type.vo.ts b/packages/services/auth-service/src/domain/value-objects/role-type.vo.ts index b2a4643..4a055e7 100644 --- a/packages/services/auth-service/src/domain/value-objects/role-type.vo.ts +++ b/packages/services/auth-service/src/domain/value-objects/role-type.vo.ts @@ -1,4 +1,5 @@ export enum RoleType { + PLATFORM_SUPER_ADMIN = 'platform_super_admin', PLATFORM_ADMIN = 'platform_admin', ADMIN = 'admin', OPERATOR = 'operator', @@ -6,7 +7,8 @@ export enum RoleType { } export const DEFAULT_ROLE_PERMISSIONS: Record = { - [RoleType.PLATFORM_ADMIN]: [], // Platform-level access, no tenant permissions + [RoleType.PLATFORM_SUPER_ADMIN]: [], // Highest platform level: all platform_admin ops + destructive ops + [RoleType.PLATFORM_ADMIN]: [], // Daily platform ops: view/edit tenants, app versions, billing plans [RoleType.ADMIN]: Object.values({} as any), // All permissions [RoleType.OPERATOR]: [ 'dashboard:view', diff --git a/packages/services/auth-service/src/interfaces/rest/controllers/tenant.controller.ts b/packages/services/auth-service/src/interfaces/rest/controllers/tenant.controller.ts index b8dcfd1..a4e87dc 100644 --- a/packages/services/auth-service/src/interfaces/rest/controllers/tenant.controller.ts +++ b/packages/services/auth-service/src/interfaces/rest/controllers/tenant.controller.ts @@ -98,7 +98,7 @@ export class TenantController { * GET /api/v1/admin/tenants */ @Get() - @Roles('platform_admin') + @Roles('platform_admin', 'platform_super_admin') async listTenants() { const tenants = await this.tenantRepository.find({ order: { createdAt: 'DESC' } }); return tenants.map((t) => this.toDto(t)); @@ -108,7 +108,7 @@ export class TenantController { * POST /api/v1/admin/tenants */ @Post() - @Roles('platform_admin') + @Roles('platform_admin', 'platform_super_admin') async createTenant( @Body() body: { @@ -343,7 +343,7 @@ export class TenantController { * GET /api/v1/admin/tenants/:id */ @Get(':id') - @Roles('platform_admin') + @Roles('platform_admin', 'platform_super_admin') async getTenant(@Param('id') id: string) { const tenant = await this.findTenantOrFail(id); const memberCount = await this.getMemberCount(tenant.slug); @@ -354,7 +354,7 @@ export class TenantController { * PATCH /api/v1/admin/tenants/:id */ @Patch(':id') - @Roles('platform_admin') + @Roles('platform_admin', 'platform_super_admin') async updateTenant(@Param('id') id: string, @Body() body: any) { const tenant = await this.findTenantOrFail(id); @@ -385,7 +385,7 @@ export class TenantController { * PUT /api/v1/admin/tenants/:id (alias for PATCH — frontend detail page uses PUT) */ @Put(':id') - @Roles('platform_admin') + @Roles('platform_admin', 'platform_super_admin') async updateTenantPut(@Param('id') id: string, @Body() body: any) { return this.updateTenant(id, body); } @@ -394,7 +394,7 @@ export class TenantController { * DELETE /api/v1/admin/tenants/:id */ @Delete(':id') - @Roles('platform_admin') + @Roles('platform_super_admin') async deleteTenant(@Param('id') id: string) { const tenant = await this.findTenantOrFail(id); diff --git a/packages/services/auth-service/src/interfaces/rest/controllers/user.controller.ts b/packages/services/auth-service/src/interfaces/rest/controllers/user.controller.ts index 6adbd69..a3f0255 100644 --- a/packages/services/auth-service/src/interfaces/rest/controllers/user.controller.ts +++ b/packages/services/auth-service/src/interfaces/rest/controllers/user.controller.ts @@ -19,7 +19,7 @@ import * as crypto from 'crypto'; @Controller('api/v1/auth/users') @UseGuards(RolesGuard) -@Roles('platform_admin') +@Roles('platform_admin', 'platform_super_admin') export class UserController { constructor( @InjectRepository(User) @@ -130,6 +130,7 @@ export class UserController { } @Delete(':id') + @Roles('platform_super_admin') async deleteUser(@Param('id') id: string) { const user = await this.userRepository.findOne({ where: { id } }); if (!user) throw new NotFoundException(`User "${id}" not found`); diff --git a/packages/services/version-service/src/guards/platform-admin.guard.ts b/packages/services/version-service/src/guards/platform-admin.guard.ts index d7dddd8..d08f2ee 100644 --- a/packages/services/version-service/src/guards/platform-admin.guard.ts +++ b/packages/services/version-service/src/guards/platform-admin.guard.ts @@ -6,42 +6,42 @@ import { UnauthorizedException, } from '@nestjs/common'; +/** Shared helper: decode JWT payload without signature verification (Kong already verified). */ +export function decodeJwtRoles(authHeader: string | undefined): string[] { + if (!authHeader?.startsWith('Bearer ')) return []; + const parts = authHeader.slice(7).split('.'); + if (parts.length !== 3) return []; + try { + const payload = JSON.parse( + Buffer.from(parts[1], 'base64url').toString('utf-8'), + ) as { roles?: string[] }; + return Array.isArray(payload.roles) ? payload.roles : []; + } catch { + return []; + } +} + +const PLATFORM_ROLES = ['platform_admin', 'platform_super_admin']; + /** - * Decodes the JWT payload from the Authorization header and checks that the - * caller has the `platform_admin` role. Signature verification is intentionally - * skipped here because Kong already validates the JWT before forwarding requests - * to this service. + * Accepts requests from `platform_admin` OR `platform_super_admin`. + * Signature verification skipped — Kong validates the JWT upstream. */ @Injectable() export class PlatformAdminGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest<{ headers: Record }>(); - const authHeader = request.headers['authorization']; + const roles = decodeJwtRoles(request.headers['authorization']); - if (!authHeader?.startsWith('Bearer ')) { + if (!request.headers['authorization']?.startsWith('Bearer ')) { throw new UnauthorizedException('Missing authorization token'); } - - const token = authHeader.slice(7); - const parts = token.split('.'); - if (parts.length !== 3) { - throw new UnauthorizedException('Invalid token format'); - } - - try { - const payload = JSON.parse( - Buffer.from(parts[1], 'base64url').toString('utf-8'), - ) as { roles?: string[] }; - - const roles: string[] = Array.isArray(payload.roles) ? payload.roles : []; - if (!roles.includes('platform_admin')) { - throw new ForbiddenException('Platform admin access required'); - } - - return true; - } catch (err) { - if (err instanceof ForbiddenException) throw err; + if (roles.length === 0) { throw new UnauthorizedException('Invalid token payload'); } + if (!roles.some((r) => PLATFORM_ROLES.includes(r))) { + throw new ForbiddenException('Platform admin access required'); + } + return true; } } diff --git a/packages/services/version-service/src/guards/platform-super-admin.guard.ts b/packages/services/version-service/src/guards/platform-super-admin.guard.ts new file mode 100644 index 0000000..1b5691b --- /dev/null +++ b/packages/services/version-service/src/guards/platform-super-admin.guard.ts @@ -0,0 +1,36 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { decodeJwtRoles } from './platform-admin.guard'; + +/** + * Requires the caller to have the `platform_super_admin` role. + * Used for destructive operations (delete version, etc.). + * Intended to be stacked on top of PlatformAdminGuard: + * class: @UseGuards(PlatformAdminGuard) → admits platform_admin + platform_super_admin + * method: @UseGuards(PlatformSuperAdminGuard) → further restricts to platform_super_admin only + */ +@Injectable() +export class PlatformSuperAdminGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ headers: Record }>(); + const authHeader = request.headers['authorization']; + + if (!authHeader?.startsWith('Bearer ')) { + throw new UnauthorizedException('Missing authorization token'); + } + + const roles = decodeJwtRoles(authHeader); + if (roles.length === 0) { + throw new UnauthorizedException('Invalid token payload'); + } + if (!roles.includes('platform_super_admin')) { + throw new ForbiddenException('Platform super admin access required for this operation'); + } + return true; + } +} diff --git a/packages/services/version-service/src/interfaces/rest/controllers/version.controller.ts b/packages/services/version-service/src/interfaces/rest/controllers/version.controller.ts index dc60014..3375e0e 100644 --- a/packages/services/version-service/src/interfaces/rest/controllers/version.controller.ts +++ b/packages/services/version-service/src/interfaces/rest/controllers/version.controller.ts @@ -17,6 +17,7 @@ import { import { FileInterceptor } from '@nestjs/platform-express'; import { ConfigService } from '@nestjs/config'; import { PlatformAdminGuard } from '../../../guards/platform-admin.guard'; +import { PlatformSuperAdminGuard } from '../../../guards/platform-super-admin.guard'; import { diskStorage } from 'multer'; import * as fs from 'fs'; import * as path from 'path'; @@ -89,6 +90,7 @@ export class VersionController { } @Delete(':id') + @UseGuards(PlatformSuperAdminGuard) async delete(@Param('id') id: string) { const version = await this.versionRepo.findById(id); if (!version) throw new NotFoundException('Version not found');