From 0ab726112955ea19a34120b8e625c67f7c1eabf2 Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 7 Mar 2026 00:57:40 -0800 Subject: [PATCH] feat(auth): introduce platform_admin role with proper access separation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 platform_admin 角色,将平台超管与租户管理员的权限彻底分离。 ## 后端变更 ### auth-service — role-type.vo.ts - 新增 RoleType.PLATFORM_ADMIN = 'platform_admin' - DEFAULT_ROLE_PERMISSIONS 中为 PLATFORM_ADMIN 添加空权限集(平台层操作,不参与租户内权限体系) ### auth-service — tenant.controller.ts - 移除类级别 @Roles('admin'),改为方法级别精细控制: - 租户 CRUD(列表/创建/GET/:id/PATCH/:id/PUT/:id/DELETE/:id)→ @Roles('platform_admin') - 成员管理(listMembers/updateMember/removeMember)→ @Roles('admin') - 邀请管理(listInvites/createInvite/revokeInvite)→ @Roles('admin') - 租户管理员可继续管理自己团队的成员和邀请,但无法访问跨租户的租户 CRUD ### auth-service — user.controller.ts - /api/v1/auth/users(跨租户用户列表/CRUD)→ @Roles('platform_admin') - 原来任意 admin 均可查看所有用户,现仅平台超管可访问 ### version-service — guards/platform-admin.guard.ts(新文件) - 新增 PlatformAdminGuard:从 Authorization: Bearer 中 base64 解码 payload, 检查 roles 包含 'platform_admin'(无需重复验签,Kong 已完成签名校验) - 不依赖 @nestjs/passport,轻量、无额外依赖 ### version-service — version.controller.ts - 整个 /api/v1/versions 控制器挂载 @UseGuards(PlatformAdminGuard) - App 版本管理(上传/发布/删除 APK/IPA)仅平台超管可操作 ## 前端变更 ### it0-web-admin — sidebar.tsx - 登录时从 localStorage.user.roles 检测是否为 platform_admin - 平台超管侧边栏:仪表盘 / 租户管理 / 用户(跨租户)/ App版本 / 账单(套餐+概览+账单记录)/ 设置 - 租户用户侧边栏:仪表盘 / Agent配置 / Runbooks / 常驻指令 / 服务器 / 监控 / 终端 / 安全 / 审计 / 通信 / 账单(概览+账单记录,无套餐管理)/ 设置 ## 创建第一个平台超管账号 直接更新数据库: UPDATE it0_t_default.users SET roles = '{platform_admin}' WHERE email = 'xxx@xxx.com'; 或通过已有 platform_admin 账号调用 POST /api/v1/auth/users 并指定 role: 'platform_admin' Co-Authored-By: Claude Sonnet 4.6 --- .../components/layout/sidebar.tsx | 40 +++++++++++++--- .../src/domain/value-objects/role-type.vo.ts | 2 + .../rest/controllers/tenant.controller.ts | 13 ++++- .../rest/controllers/user.controller.ts | 2 +- .../src/guards/platform-admin.guard.ts | 47 +++++++++++++++++++ .../rest/controllers/version.controller.ts | 3 ++ 6 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 packages/services/version-service/src/guards/platform-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 f4eeafd..732a021 100644 --- a/it0-web-admin/src/presentation/components/layout/sidebar.tsx +++ b/it0-web-admin/src/presentation/components/layout/sidebar.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, createContext, useContext } from 'react'; +import { useState, useMemo, createContext, useContext, useEffect } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useTranslation } from 'react-i18next'; @@ -88,8 +88,38 @@ export function Sidebar() { const { t } = useTranslation('sidebar'); const [expanded, setExpanded] = useState>({}); const [collapsed, setCollapsed] = useState(false); + const [isPlatformAdmin, setIsPlatformAdmin] = useState(false); - const navItems: NavItem[] = useMemo(() => [ + useEffect(() => { + try { + 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')); + } + } catch { /* ignore */ } + }, []); + + const platformAdminItems: NavItem[] = useMemo(() => [ + { key: 'dashboard', label: t('dashboard'), href: '/dashboard', icon: }, + { key: 'tenants', label: t('tenants'), href: '/tenants', icon: }, + { key: 'users', label: t('users'), href: '/users', icon: }, + { key: 'appVersions', label: t('appVersions'), href: '/app-versions', icon: }, + { + key: 'billing', + label: t('billing'), + href: '/billing/plans', + icon: , + children: [ + { label: t('billingPlans'), href: '/billing/plans' }, + { label: t('billingOverview'), href: '/billing' }, + { label: t('billingInvoices'), href: '/billing/invoices' }, + ], + }, + { key: 'settings', label: t('settings'), href: '/settings', icon: }, + ], [t]); + + const tenantNavItems: NavItem[] = useMemo(() => [ { key: 'dashboard', label: t('dashboard'), href: '/dashboard', icon: }, { key: 'agentConfig', @@ -157,16 +187,14 @@ export function Sidebar() { icon: , children: [ { label: t('billingOverview'), href: '/billing' }, - { label: t('billingPlans'), href: '/billing/plans' }, { label: t('billingInvoices'), href: '/billing/invoices' }, ], }, - { key: 'appVersions', label: t('appVersions'), href: '/app-versions', icon: }, - { key: 'tenants', label: t('tenants'), href: '/tenants', icon: }, - { key: 'users', label: t('users'), href: '/users', icon: }, { key: 'settings', label: t('settings'), href: '/settings', icon: }, ], [t]); + const navItems = isPlatformAdmin ? platformAdminItems : tenantNavItems; + const toggle = (key: string) => setExpanded((prev) => ({ ...prev, [key]: !prev[key] })); 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 a1f29d4..b2a4643 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,10 +1,12 @@ export enum RoleType { + PLATFORM_ADMIN = 'platform_admin', ADMIN = 'admin', OPERATOR = 'operator', VIEWER = 'viewer', } export const DEFAULT_ROLE_PERMISSIONS: Record = { + [RoleType.PLATFORM_ADMIN]: [], // Platform-level access, no tenant permissions [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 dfbe43e..b8dcfd1 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 @@ -23,7 +23,6 @@ import * as crypto from 'crypto'; @Controller('api/v1/admin/tenants') @UseGuards(RolesGuard) -@Roles('admin') export class TenantController { private readonly logger = new Logger(TenantController.name); @@ -99,6 +98,7 @@ export class TenantController { * GET /api/v1/admin/tenants */ @Get() + @Roles('platform_admin') async listTenants() { const tenants = await this.tenantRepository.find({ order: { createdAt: 'DESC' } }); return tenants.map((t) => this.toDto(t)); @@ -108,6 +108,7 @@ export class TenantController { * POST /api/v1/admin/tenants */ @Post() + @Roles('platform_admin') async createTenant( @Body() body: { @@ -155,6 +156,7 @@ export class TenantController { * GET /api/v1/admin/tenants/:id/members */ @Get(':id/members') + @Roles('admin') async listMembers(@Param('id') id: string) { const tenant = await this.findTenantOrFail(id); const schemaName = `it0_t_${tenant.slug}`; @@ -182,6 +184,7 @@ export class TenantController { * PATCH /api/v1/admin/tenants/:id/members/:memberId */ @Patch(':id/members/:memberId') + @Roles('admin') async updateMember( @Param('id') tenantId: string, @Param('memberId') memberId: string, @@ -247,6 +250,7 @@ export class TenantController { * DELETE /api/v1/admin/tenants/:id/members/:memberId */ @Delete(':id/members/:memberId') + @Roles('admin') async removeMember( @Param('id') tenantId: string, @Param('memberId') memberId: string, @@ -279,6 +283,7 @@ export class TenantController { * GET /api/v1/admin/tenants/:id/invites */ @Get(':id/invites') + @Roles('admin') async listInvites(@Param('id') tenantId: string) { const invites = await this.authService.listInvites(tenantId); return invites.map((inv) => ({ @@ -296,6 +301,7 @@ export class TenantController { * POST /api/v1/admin/tenants/:id/invites */ @Post(':id/invites') + @Roles('admin') async createInvite( @Param('id') tenantId: string, @Body() body: { email: string; role?: string }, @@ -322,6 +328,7 @@ export class TenantController { * DELETE /api/v1/admin/tenants/:id/invites/:inviteId */ @Delete(':id/invites/:inviteId') + @Roles('admin') async revokeInvite( @Param('id') tenantId: string, @Param('inviteId') inviteId: string, @@ -336,6 +343,7 @@ export class TenantController { * GET /api/v1/admin/tenants/:id */ @Get(':id') + @Roles('platform_admin') async getTenant(@Param('id') id: string) { const tenant = await this.findTenantOrFail(id); const memberCount = await this.getMemberCount(tenant.slug); @@ -346,6 +354,7 @@ export class TenantController { * PATCH /api/v1/admin/tenants/:id */ @Patch(':id') + @Roles('platform_admin') async updateTenant(@Param('id') id: string, @Body() body: any) { const tenant = await this.findTenantOrFail(id); @@ -376,6 +385,7 @@ export class TenantController { * PUT /api/v1/admin/tenants/:id (alias for PATCH — frontend detail page uses PUT) */ @Put(':id') + @Roles('platform_admin') async updateTenantPut(@Param('id') id: string, @Body() body: any) { return this.updateTenant(id, body); } @@ -384,6 +394,7 @@ export class TenantController { * DELETE /api/v1/admin/tenants/:id */ @Delete(':id') + @Roles('platform_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 2544be7..6adbd69 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('admin') +@Roles('platform_admin') export class UserController { constructor( @InjectRepository(User) diff --git a/packages/services/version-service/src/guards/platform-admin.guard.ts b/packages/services/version-service/src/guards/platform-admin.guard.ts new file mode 100644 index 0000000..d7dddd8 --- /dev/null +++ b/packages/services/version-service/src/guards/platform-admin.guard.ts @@ -0,0 +1,47 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; + +/** + * 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. + */ +@Injectable() +export class PlatformAdminGuard 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 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; + throw new UnauthorizedException('Invalid token payload'); + } + } +} 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 dac8fa0..dc60014 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 @@ -9,12 +9,14 @@ import { Query, Body, UploadedFile, + UseGuards, UseInterceptors, NotFoundException, Logger, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ConfigService } from '@nestjs/config'; +import { PlatformAdminGuard } from '../../../guards/platform-admin.guard'; import { diskStorage } from 'multer'; import * as fs from 'fs'; import * as path from 'path'; @@ -26,6 +28,7 @@ import { UpdateVersionDto } from '../../../application/dtos/update-version.dto'; const UPLOAD_DIR = '/data/versions'; @Controller('api/v1/versions') +@UseGuards(PlatformAdminGuard) export class VersionController { private readonly logger = new Logger(VersionController.name); private readonly downloadBaseUrl: string;