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:
parent
0ab7261129
commit
816c5461f9
|
|
@ -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 */ }
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue