feat(auth): add platform_super_admin role for two-level platform access control

在 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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-07 01:17:27 -08:00
parent 0ab7261129
commit 816c5461f9
7 changed files with 79 additions and 35 deletions

View File

@ -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 */ }
}, []);

View File

@ -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, string[]> = {
[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',

View File

@ -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);

View File

@ -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`);

View File

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

View File

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

View File

@ -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');