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:
parent
ecc64e0ff9
commit
0ab7261129
|
|
@ -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<Record<string, boolean>>({});
|
||||
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: 'agentConfig',
|
||||
|
|
@ -157,16 +187,14 @@ export function Sidebar() {
|
|||
icon: <CreditCard className={iconClass} />,
|
||||
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: <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} /> },
|
||||
], [t]);
|
||||
|
||||
const navItems = isPlatformAdmin ? platformAdminItems : tenantNavItems;
|
||||
|
||||
const toggle = (key: string) =>
|
||||
setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
export enum RoleType {
|
||||
PLATFORM_ADMIN = 'platform_admin',
|
||||
ADMIN = 'admin',
|
||||
OPERATOR = 'operator',
|
||||
VIEWER = 'viewer',
|
||||
}
|
||||
|
||||
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.OPERATOR]: [
|
||||
'dashboard:view',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue