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');
|
const raw = localStorage.getItem('user');
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const user = JSON.parse(raw) as { roles?: string[] };
|
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 */ }
|
} catch { /* ignore */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export enum RoleType {
|
export enum RoleType {
|
||||||
|
PLATFORM_SUPER_ADMIN = 'platform_super_admin',
|
||||||
PLATFORM_ADMIN = 'platform_admin',
|
PLATFORM_ADMIN = 'platform_admin',
|
||||||
ADMIN = 'admin',
|
ADMIN = 'admin',
|
||||||
OPERATOR = 'operator',
|
OPERATOR = 'operator',
|
||||||
|
|
@ -6,7 +7,8 @@ export enum RoleType {
|
||||||
}
|
}
|
||||||
|
|
||||||
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.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.ADMIN]: Object.values({} as any), // All permissions
|
||||||
[RoleType.OPERATOR]: [
|
[RoleType.OPERATOR]: [
|
||||||
'dashboard:view',
|
'dashboard:view',
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ export class TenantController {
|
||||||
* GET /api/v1/admin/tenants
|
* GET /api/v1/admin/tenants
|
||||||
*/
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
@Roles('platform_admin')
|
@Roles('platform_admin', 'platform_super_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,7 +108,7 @@ export class TenantController {
|
||||||
* POST /api/v1/admin/tenants
|
* POST /api/v1/admin/tenants
|
||||||
*/
|
*/
|
||||||
@Post()
|
@Post()
|
||||||
@Roles('platform_admin')
|
@Roles('platform_admin', 'platform_super_admin')
|
||||||
async createTenant(
|
async createTenant(
|
||||||
@Body()
|
@Body()
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -343,7 +343,7 @@ export class TenantController {
|
||||||
* GET /api/v1/admin/tenants/:id
|
* GET /api/v1/admin/tenants/:id
|
||||||
*/
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Roles('platform_admin')
|
@Roles('platform_admin', 'platform_super_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);
|
||||||
|
|
@ -354,7 +354,7 @@ export class TenantController {
|
||||||
* PATCH /api/v1/admin/tenants/:id
|
* PATCH /api/v1/admin/tenants/:id
|
||||||
*/
|
*/
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles('platform_admin')
|
@Roles('platform_admin', 'platform_super_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);
|
||||||
|
|
||||||
|
|
@ -385,7 +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')
|
@Roles('platform_admin', 'platform_super_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);
|
||||||
}
|
}
|
||||||
|
|
@ -394,7 +394,7 @@ export class TenantController {
|
||||||
* DELETE /api/v1/admin/tenants/:id
|
* DELETE /api/v1/admin/tenants/:id
|
||||||
*/
|
*/
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Roles('platform_admin')
|
@Roles('platform_super_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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import * as crypto from 'crypto';
|
||||||
|
|
||||||
@Controller('api/v1/auth/users')
|
@Controller('api/v1/auth/users')
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
@Roles('platform_admin')
|
@Roles('platform_admin', 'platform_super_admin')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
|
|
@ -130,6 +130,7 @@ export class UserController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@Roles('platform_super_admin')
|
||||||
async deleteUser(@Param('id') id: string) {
|
async deleteUser(@Param('id') id: string) {
|
||||||
const user = await this.userRepository.findOne({ where: { id } });
|
const user = await this.userRepository.findOne({ where: { id } });
|
||||||
if (!user) throw new NotFoundException(`User "${id}" not found`);
|
if (!user) throw new NotFoundException(`User "${id}" not found`);
|
||||||
|
|
|
||||||
|
|
@ -6,42 +6,42 @@ import {
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} 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
|
* Accepts requests from `platform_admin` OR `platform_super_admin`.
|
||||||
* caller has the `platform_admin` role. Signature verification is intentionally
|
* Signature verification skipped — Kong validates the JWT upstream.
|
||||||
* skipped here because Kong already validates the JWT before forwarding requests
|
|
||||||
* to this service.
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PlatformAdminGuard implements CanActivate {
|
export class PlatformAdminGuard implements CanActivate {
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const request = context.switchToHttp().getRequest<{ headers: Record<string, string> }>();
|
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');
|
throw new UnauthorizedException('Missing authorization token');
|
||||||
}
|
}
|
||||||
|
if (roles.length === 0) {
|
||||||
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');
|
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 { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PlatformAdminGuard } from '../../../guards/platform-admin.guard';
|
import { PlatformAdminGuard } from '../../../guards/platform-admin.guard';
|
||||||
|
import { PlatformSuperAdminGuard } from '../../../guards/platform-super-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';
|
||||||
|
|
@ -89,6 +90,7 @@ export class VersionController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@UseGuards(PlatformSuperAdminGuard)
|
||||||
async delete(@Param('id') id: string) {
|
async delete(@Param('id') id: string) {
|
||||||
const version = await this.versionRepo.findById(id);
|
const version = await this.versionRepo.findById(id);
|
||||||
if (!version) throw new NotFoundException('Version not found');
|
if (!version) throw new NotFoundException('Version not found');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue