feat(auth): introduce platform_admin role with proper access separation

新增 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 <JWT> 中 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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-07 00:57:40 -08:00
parent ecc64e0ff9
commit 0ab7261129
6 changed files with 99 additions and 8 deletions

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useMemo, createContext, useContext } from 'react'; import { useState, useMemo, createContext, useContext, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -88,8 +88,38 @@ export function Sidebar() {
const { t } = useTranslation('sidebar'); const { t } = useTranslation('sidebar');
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [collapsed, setCollapsed] = useState(false); 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: <LayoutDashboard className={iconClass} /> },
{ key: 'tenants', label: t('tenants'), href: '/tenants', icon: <Building2 className={iconClass} /> },
{ key: 'users', label: t('users'), href: '/users', icon: <Users className={iconClass} /> },
{ key: 'appVersions', label: t('appVersions'), href: '/app-versions', icon: <Smartphone className={iconClass} /> },
{
key: 'billing',
label: t('billing'),
href: '/billing/plans',
icon: <CreditCard className={iconClass} />,
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: <Settings className={iconClass} /> },
], [t]);
const tenantNavItems: NavItem[] = useMemo(() => [
{ key: 'dashboard', label: t('dashboard'), href: '/dashboard', icon: <LayoutDashboard className={iconClass} /> }, { key: 'dashboard', label: t('dashboard'), href: '/dashboard', icon: <LayoutDashboard className={iconClass} /> },
{ {
key: 'agentConfig', key: 'agentConfig',
@ -157,16 +187,14 @@ export function Sidebar() {
icon: <CreditCard className={iconClass} />, icon: <CreditCard className={iconClass} />,
children: [ children: [
{ label: t('billingOverview'), href: '/billing' }, { label: t('billingOverview'), href: '/billing' },
{ label: t('billingPlans'), href: '/billing/plans' },
{ label: t('billingInvoices'), href: '/billing/invoices' }, { label: t('billingInvoices'), href: '/billing/invoices' },
], ],
}, },
{ key: 'appVersions', label: t('appVersions'), href: '/app-versions', icon: <Smartphone className={iconClass} /> },
{ key: 'tenants', label: t('tenants'), href: '/tenants', icon: <Building2 className={iconClass} /> },
{ key: 'users', label: t('users'), href: '/users', icon: <Users className={iconClass} /> },
{ key: 'settings', label: t('settings'), href: '/settings', icon: <Settings className={iconClass} /> }, { key: 'settings', label: t('settings'), href: '/settings', icon: <Settings className={iconClass} /> },
], [t]); ], [t]);
const navItems = isPlatformAdmin ? platformAdminItems : tenantNavItems;
const toggle = (key: string) => const toggle = (key: string) =>
setExpanded((prev) => ({ ...prev, [key]: !prev[key] })); setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));

View File

@ -1,10 +1,12 @@
export enum RoleType { export enum RoleType {
PLATFORM_ADMIN = 'platform_admin',
ADMIN = 'admin', ADMIN = 'admin',
OPERATOR = 'operator', OPERATOR = 'operator',
VIEWER = 'viewer', VIEWER = 'viewer',
} }
export const DEFAULT_ROLE_PERMISSIONS: Record<RoleType, string[]> = { export const DEFAULT_ROLE_PERMISSIONS: Record<RoleType, string[]> = {
[RoleType.PLATFORM_ADMIN]: [], // Platform-level access, no tenant permissions
[RoleType.ADMIN]: Object.values({} as any), // All permissions [RoleType.ADMIN]: Object.values({} as any), // All permissions
[RoleType.OPERATOR]: [ [RoleType.OPERATOR]: [
'dashboard:view', 'dashboard:view',

View File

@ -23,7 +23,6 @@ import * as crypto from 'crypto';
@Controller('api/v1/admin/tenants') @Controller('api/v1/admin/tenants')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles('admin')
export class TenantController { export class TenantController {
private readonly logger = new Logger(TenantController.name); private readonly logger = new Logger(TenantController.name);
@ -99,6 +98,7 @@ export class TenantController {
* GET /api/v1/admin/tenants * GET /api/v1/admin/tenants
*/ */
@Get() @Get()
@Roles('platform_admin')
async listTenants() { async listTenants() {
const tenants = await this.tenantRepository.find({ order: { createdAt: 'DESC' } }); const tenants = await this.tenantRepository.find({ order: { createdAt: 'DESC' } });
return tenants.map((t) => this.toDto(t)); return tenants.map((t) => this.toDto(t));
@ -108,6 +108,7 @@ export class TenantController {
* POST /api/v1/admin/tenants * POST /api/v1/admin/tenants
*/ */
@Post() @Post()
@Roles('platform_admin')
async createTenant( async createTenant(
@Body() @Body()
body: { body: {
@ -155,6 +156,7 @@ export class TenantController {
* GET /api/v1/admin/tenants/:id/members * GET /api/v1/admin/tenants/:id/members
*/ */
@Get(':id/members') @Get(':id/members')
@Roles('admin')
async listMembers(@Param('id') id: string) { async listMembers(@Param('id') id: string) {
const tenant = await this.findTenantOrFail(id); const tenant = await this.findTenantOrFail(id);
const schemaName = `it0_t_${tenant.slug}`; const schemaName = `it0_t_${tenant.slug}`;
@ -182,6 +184,7 @@ export class TenantController {
* PATCH /api/v1/admin/tenants/:id/members/:memberId * PATCH /api/v1/admin/tenants/:id/members/:memberId
*/ */
@Patch(':id/members/:memberId') @Patch(':id/members/:memberId')
@Roles('admin')
async updateMember( async updateMember(
@Param('id') tenantId: string, @Param('id') tenantId: string,
@Param('memberId') memberId: string, @Param('memberId') memberId: string,
@ -247,6 +250,7 @@ export class TenantController {
* DELETE /api/v1/admin/tenants/:id/members/:memberId * DELETE /api/v1/admin/tenants/:id/members/:memberId
*/ */
@Delete(':id/members/:memberId') @Delete(':id/members/:memberId')
@Roles('admin')
async removeMember( async removeMember(
@Param('id') tenantId: string, @Param('id') tenantId: string,
@Param('memberId') memberId: string, @Param('memberId') memberId: string,
@ -279,6 +283,7 @@ export class TenantController {
* GET /api/v1/admin/tenants/:id/invites * GET /api/v1/admin/tenants/:id/invites
*/ */
@Get(':id/invites') @Get(':id/invites')
@Roles('admin')
async listInvites(@Param('id') tenantId: string) { async listInvites(@Param('id') tenantId: string) {
const invites = await this.authService.listInvites(tenantId); const invites = await this.authService.listInvites(tenantId);
return invites.map((inv) => ({ return invites.map((inv) => ({
@ -296,6 +301,7 @@ export class TenantController {
* POST /api/v1/admin/tenants/:id/invites * POST /api/v1/admin/tenants/:id/invites
*/ */
@Post(':id/invites') @Post(':id/invites')
@Roles('admin')
async createInvite( async createInvite(
@Param('id') tenantId: string, @Param('id') tenantId: string,
@Body() body: { email: string; role?: string }, @Body() body: { email: string; role?: string },
@ -322,6 +328,7 @@ export class TenantController {
* DELETE /api/v1/admin/tenants/:id/invites/:inviteId * DELETE /api/v1/admin/tenants/:id/invites/:inviteId
*/ */
@Delete(':id/invites/:inviteId') @Delete(':id/invites/:inviteId')
@Roles('admin')
async revokeInvite( async revokeInvite(
@Param('id') tenantId: string, @Param('id') tenantId: string,
@Param('inviteId') inviteId: string, @Param('inviteId') inviteId: string,
@ -336,6 +343,7 @@ export class TenantController {
* GET /api/v1/admin/tenants/:id * GET /api/v1/admin/tenants/:id
*/ */
@Get(':id') @Get(':id')
@Roles('platform_admin')
async getTenant(@Param('id') id: string) { async getTenant(@Param('id') id: string) {
const tenant = await this.findTenantOrFail(id); const tenant = await this.findTenantOrFail(id);
const memberCount = await this.getMemberCount(tenant.slug); const memberCount = await this.getMemberCount(tenant.slug);
@ -346,6 +354,7 @@ export class TenantController {
* PATCH /api/v1/admin/tenants/:id * PATCH /api/v1/admin/tenants/:id
*/ */
@Patch(':id') @Patch(':id')
@Roles('platform_admin')
async updateTenant(@Param('id') id: string, @Body() body: any) { async updateTenant(@Param('id') id: string, @Body() body: any) {
const tenant = await this.findTenantOrFail(id); 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 /api/v1/admin/tenants/:id (alias for PATCH frontend detail page uses PUT)
*/ */
@Put(':id') @Put(':id')
@Roles('platform_admin')
async updateTenantPut(@Param('id') id: string, @Body() body: any) { async updateTenantPut(@Param('id') id: string, @Body() body: any) {
return this.updateTenant(id, body); return this.updateTenant(id, body);
} }
@ -384,6 +394,7 @@ export class TenantController {
* DELETE /api/v1/admin/tenants/:id * DELETE /api/v1/admin/tenants/:id
*/ */
@Delete(':id') @Delete(':id')
@Roles('platform_admin')
async deleteTenant(@Param('id') id: string) { async deleteTenant(@Param('id') id: string) {
const tenant = await this.findTenantOrFail(id); const tenant = await this.findTenantOrFail(id);

View File

@ -19,7 +19,7 @@ import * as crypto from 'crypto';
@Controller('api/v1/auth/users') @Controller('api/v1/auth/users')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles('admin') @Roles('platform_admin')
export class UserController { export class UserController {
constructor( constructor(
@InjectRepository(User) @InjectRepository(User)

View File

@ -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<string, string> }>();
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');
}
}
}

View File

@ -9,12 +9,14 @@ import {
Query, Query,
Body, Body,
UploadedFile, UploadedFile,
UseGuards,
UseInterceptors, UseInterceptors,
NotFoundException, NotFoundException,
Logger, Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PlatformAdminGuard } from '../../../guards/platform-admin.guard';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -26,6 +28,7 @@ import { UpdateVersionDto } from '../../../application/dtos/update-version.dto';
const UPLOAD_DIR = '/data/versions'; const UPLOAD_DIR = '/data/versions';
@Controller('api/v1/versions') @Controller('api/v1/versions')
@UseGuards(PlatformAdminGuard)
export class VersionController { export class VersionController {
private readonly logger = new Logger(VersionController.name); private readonly logger = new Logger(VersionController.name);
private readonly downloadBaseUrl: string; private readonly downloadBaseUrl: string;