feat: add settings, roles, permissions, and metrics controllers

Implement remaining backend controllers for all web admin menu pages:
- SettingsController: general, notification, theme, account, API keys
- RoleController: CRUD roles with permission assignment
- PermissionController: permission matrix for RBAC management
- MetricsController: server metrics overview and per-server data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-22 01:03:34 -08:00
parent 8f89b8121c
commit 7dbd2c1414
6 changed files with 543 additions and 2 deletions

View File

@ -7,6 +7,9 @@ import { DatabaseModule, TenantProvisioningService } from '@it0/database';
import { AuthController } from './interfaces/rest/controllers/auth.controller';
import { TenantController } from './interfaces/rest/controllers/tenant.controller';
import { UserController } from './interfaces/rest/controllers/user.controller';
import { SettingsController } from './interfaces/rest/controllers/settings.controller';
import { RoleController } from './interfaces/rest/controllers/role.controller';
import { PermissionController } from './interfaces/rest/controllers/permission.controller';
import { JwtStrategy } from './infrastructure/strategies/jwt.strategy';
import { RbacGuard } from './infrastructure/guards/rbac.guard';
import { AuthService } from './application/services/auth.service';
@ -30,7 +33,7 @@ import { Tenant } from './domain/entities/tenant.entity';
}),
}),
],
controllers: [AuthController, TenantController, UserController],
controllers: [AuthController, TenantController, UserController, SettingsController, RoleController, PermissionController],
providers: [
JwtStrategy,
RbacGuard,

View File

@ -0,0 +1,110 @@
import {
Controller,
Get,
Patch,
Body,
UseGuards,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RolesGuard, Roles } from '@it0/common';
import { Role } from '../../../domain/entities/role.entity';
// All available permissions in the system
const ALL_PERMISSIONS = [
{ id: 'servers:create', key: 'servers:create', resource: 'servers', action: 'create', description: 'Create servers' },
{ id: 'servers:read', key: 'servers:read', resource: 'servers', action: 'read', description: 'View servers' },
{ id: 'servers:update', key: 'servers:update', resource: 'servers', action: 'update', description: 'Update servers' },
{ id: 'servers:delete', key: 'servers:delete', resource: 'servers', action: 'delete', description: 'Delete servers' },
{ id: 'servers:execute', key: 'servers:execute', resource: 'servers', action: 'execute', description: 'Execute commands on servers' },
{ id: 'tasks:create', key: 'tasks:create', resource: 'tasks', action: 'create', description: 'Create tasks' },
{ id: 'tasks:read', key: 'tasks:read', resource: 'tasks', action: 'read', description: 'View tasks' },
{ id: 'tasks:update', key: 'tasks:update', resource: 'tasks', action: 'update', description: 'Update tasks' },
{ id: 'tasks:delete', key: 'tasks:delete', resource: 'tasks', action: 'delete', description: 'Delete tasks' },
{ id: 'tasks:execute', key: 'tasks:execute', resource: 'tasks', action: 'execute', description: 'Execute tasks' },
{ id: 'alerts:create', key: 'alerts:create', resource: 'alerts', action: 'create', description: 'Create alert rules' },
{ id: 'alerts:read', key: 'alerts:read', resource: 'alerts', action: 'read', description: 'View alerts' },
{ id: 'alerts:update', key: 'alerts:update', resource: 'alerts', action: 'update', description: 'Update alert rules' },
{ id: 'alerts:delete', key: 'alerts:delete', resource: 'alerts', action: 'delete', description: 'Delete alert rules' },
{ id: 'users:create', key: 'users:create', resource: 'users', action: 'create', description: 'Create users' },
{ id: 'users:read', key: 'users:read', resource: 'users', action: 'read', description: 'View users' },
{ id: 'users:update', key: 'users:update', resource: 'users', action: 'update', description: 'Update users' },
{ id: 'users:delete', key: 'users:delete', resource: 'users', action: 'delete', description: 'Delete users' },
{ id: 'tenants:create', key: 'tenants:create', resource: 'tenants', action: 'create', description: 'Create tenants' },
{ id: 'tenants:read', key: 'tenants:read', resource: 'tenants', action: 'read', description: 'View tenants' },
{ id: 'tenants:update', key: 'tenants:update', resource: 'tenants', action: 'update', description: 'Update tenants' },
{ id: 'tenants:delete', key: 'tenants:delete', resource: 'tenants', action: 'delete', description: 'Delete tenants' },
{ id: 'agent:create', key: 'agent:create', resource: 'agent', action: 'create', description: 'Create agent sessions' },
{ id: 'agent:read', key: 'agent:read', resource: 'agent', action: 'read', description: 'View agent data' },
{ id: 'agent:update', key: 'agent:update', resource: 'agent', action: 'update', description: 'Update agent config' },
{ id: 'agent:execute', key: 'agent:execute', resource: 'agent', action: 'execute', description: 'Execute agent tasks' },
{ id: 'credentials:create', key: 'credentials:create', resource: 'credentials', action: 'create', description: 'Create credentials' },
{ id: 'credentials:read', key: 'credentials:read', resource: 'credentials', action: 'read', description: 'View credentials' },
{ id: 'credentials:update', key: 'credentials:update', resource: 'credentials', action: 'update', description: 'Update credentials' },
{ id: 'credentials:delete', key: 'credentials:delete', resource: 'credentials', action: 'delete', description: 'Delete credentials' },
{ id: 'settings:read', key: 'settings:read', resource: 'settings', action: 'read', description: 'View settings' },
{ id: 'settings:update', key: 'settings:update', resource: 'settings', action: 'update', description: 'Update settings' },
];
@Controller('api/v1/auth/permissions')
@UseGuards(RolesGuard)
@Roles('admin')
export class PermissionController {
constructor(
@InjectRepository(Role)
private readonly roleRepository: Repository<Role>,
) {}
@Get()
async listPermissions() {
return { data: ALL_PERMISSIONS };
}
@Get('matrix')
async getMatrix() {
const roles = await this.roleRepository.find({ order: { createdAt: 'ASC' } });
const rolesDto = roles.map((r) => ({
id: r.id,
name: r.name,
isSystem: ['admin', 'operator', 'viewer'].includes(r.name),
}));
const matrix: { roleId: string; permissionId: string; granted: boolean }[] = [];
for (const role of roles) {
for (const perm of ALL_PERMISSIONS) {
matrix.push({
roleId: role.id,
permissionId: perm.id,
granted: role.permissions?.includes(perm.key) ?? false,
});
}
}
return {
roles: rolesDto,
permissions: ALL_PERMISSIONS,
matrix,
};
}
@Patch('matrix')
async updateMatrix(
@Body() body: { roleId: string; permissionId: string; grant: boolean },
) {
const role = await this.roleRepository.findOne({ where: { id: body.roleId } });
if (!role) return { success: false };
if (body.grant) {
if (!role.permissions.includes(body.permissionId)) {
role.permissions = [...role.permissions, body.permissionId];
}
} else {
role.permissions = role.permissions.filter((p) => p !== body.permissionId);
}
await this.roleRepository.save(role);
return { success: true };
}
}

View File

@ -0,0 +1,153 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Param,
Body,
UseGuards,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RolesGuard, Roles } from '@it0/common';
import { Role } from '../../../domain/entities/role.entity';
import { User } from '../../../domain/entities/user.entity';
import * as crypto from 'crypto';
@Controller('api/v1/auth/roles')
@UseGuards(RolesGuard)
@Roles('admin')
export class RoleController {
constructor(
@InjectRepository(Role)
private readonly roleRepository: Repository<Role>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
@Get()
async listRoles() {
const roles = await this.roleRepository.find({ order: { createdAt: 'DESC' } });
const users = await this.userRepository.find();
const data = roles.map((r) => {
const userCount = users.filter((u) => u.roles?.includes(r.name)).length;
return {
id: r.id,
name: r.name,
description: r.description ?? '',
permissionCount: r.permissions?.length ?? 0,
userCount,
isSystem: ['admin', 'operator', 'viewer'].includes(r.name),
createdAt: r.createdAt.toISOString(),
};
});
return { data, total: data.length };
}
@Post()
async createRole(@Body() body: { name: string; description?: string }) {
const role = this.roleRepository.create({
id: crypto.randomUUID(),
tenantId: 'default',
name: body.name,
description: body.description ?? '',
permissions: [],
});
const saved = await this.roleRepository.save(role);
return {
id: saved.id,
name: saved.name,
description: saved.description ?? '',
permissionCount: 0,
userCount: 0,
isSystem: false,
createdAt: saved.createdAt.toISOString(),
};
}
@Put(':id')
async updateRole(
@Param('id') id: string,
@Body() body: { name?: string; description?: string },
) {
const role = await this.roleRepository.findOne({ where: { id } });
if (!role) throw new NotFoundException(`Role "${id}" not found`);
if (body.name !== undefined) role.name = body.name;
if (body.description !== undefined) role.description = body.description;
const saved = await this.roleRepository.save(role);
return {
id: saved.id,
name: saved.name,
description: saved.description ?? '',
permissionCount: saved.permissions?.length ?? 0,
isSystem: ['admin', 'operator', 'viewer'].includes(saved.name),
createdAt: saved.createdAt.toISOString(),
};
}
@Delete(':id')
async deleteRole(@Param('id') id: string) {
const role = await this.roleRepository.findOne({ where: { id } });
if (!role) throw new NotFoundException(`Role "${id}" not found`);
await this.roleRepository.remove(role);
return { success: true };
}
@Get(':roleId/permissions')
async getRolePermissions(@Param('roleId') roleId: string) {
const role = await this.roleRepository.findOne({ where: { id: roleId } });
if (!role) throw new NotFoundException(`Role "${roleId}" not found`);
// Map permission strings to Permission objects
const data = (role.permissions ?? []).map((key, idx) => {
const [resource, action] = key.includes(':') ? key.split(':') : ['general', key];
return {
id: `${roleId}-perm-${idx}`,
key,
resource,
action,
description: `${action} access to ${resource}`,
};
});
return { data };
}
@Post(':roleId/permissions')
async assignPermission(
@Param('roleId') roleId: string,
@Body() body: { permissionId: string },
) {
const role = await this.roleRepository.findOne({ where: { id: roleId } });
if (!role) throw new NotFoundException(`Role "${roleId}" not found`);
// permissionId is the permission key string (e.g., "servers:read")
if (!role.permissions.includes(body.permissionId)) {
role.permissions = [...role.permissions, body.permissionId];
await this.roleRepository.save(role);
}
return { success: true };
}
@Delete(':roleId/permissions')
async revokePermission(
@Param('roleId') roleId: string,
@Body() body: { permissionId: string },
) {
const role = await this.roleRepository.findOne({ where: { id: roleId } });
if (!role) throw new NotFoundException(`Role "${roleId}" not found`);
role.permissions = role.permissions.filter((p) => p !== body.permissionId);
await this.roleRepository.save(role);
return { success: true };
}
}

View File

@ -0,0 +1,190 @@
import {
Controller,
Get,
Put,
Post,
Delete,
Param,
Body,
Req,
UseGuards,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RolesGuard, Roles } from '@it0/common';
import { User } from '../../../domain/entities/user.entity';
import { ApiKey } from '../../../domain/entities/api-key.entity';
import * as bcrypt from 'bcryptjs';
import * as crypto from 'crypto';
@Controller('api/v1/admin/settings')
@UseGuards(RolesGuard)
@Roles('admin')
export class SettingsController {
// In-memory store for platform settings (would be a DB table in production)
private generalSettings: Record<string, any> = {
platformName: 'IT0',
defaultTimezone: 'UTC',
defaultLanguage: 'en',
autoApproveThreshold: 1,
};
private notificationSettings: Record<string, any> = {
emailEnabled: false,
smsEnabled: false,
pushEnabled: true,
defaultEscalationPolicy: 'immediate',
};
private themeSettings: Record<string, any> = {
mode: 'dark',
primaryColor: '#3b82f6',
};
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(ApiKey)
private readonly apiKeyRepository: Repository<ApiKey>,
) {}
// --- General Settings ---
@Get('general')
async getGeneral() {
return this.generalSettings;
}
@Put('general')
async updateGeneral(@Body() body: any) {
Object.assign(this.generalSettings, body);
return this.generalSettings;
}
// --- Notification Settings ---
@Get('notifications')
async getNotifications() {
return this.notificationSettings;
}
@Put('notifications')
async updateNotifications(@Body() body: any) {
Object.assign(this.notificationSettings, body);
return this.notificationSettings;
}
// --- API Keys ---
@Get('api-keys')
async listApiKeys(@Req() req: any) {
const userId = req.user?.sub || req.user?.id;
const keys = await this.apiKeyRepository.find({
where: userId ? { userId } : {},
order: { createdAt: 'DESC' },
});
return keys.map((k) => ({
id: k.id,
name: k.name,
key: '****' + k.keyHash.slice(-8),
createdAt: k.createdAt.toISOString(),
lastUsedAt: k.lastUsedAt?.toISOString() ?? null,
}));
}
@Post('api-keys')
async createApiKey(@Req() req: any, @Body() body: { name: string }) {
const userId = req.user?.sub || req.user?.id;
const tenantId = req.user?.tenantId || 'default';
const rawKey = `it0_${crypto.randomBytes(32).toString('hex')}`;
const keyHash = await bcrypt.hash(rawKey, 10);
const apiKey = this.apiKeyRepository.create({
id: crypto.randomUUID(),
tenantId,
userId,
keyHash,
name: body.name,
permissions: [],
isActive: true,
});
await this.apiKeyRepository.save(apiKey);
return { key: rawKey };
}
@Delete('api-keys/:id')
async deleteApiKey(@Param('id') id: string) {
const key = await this.apiKeyRepository.findOne({ where: { id } });
if (!key) throw new NotFoundException(`API key "${id}" not found`);
await this.apiKeyRepository.remove(key);
return { success: true };
}
// --- Theme Settings ---
@Get('theme')
async getTheme() {
return this.themeSettings;
}
@Put('theme')
async updateTheme(@Body() body: any) {
Object.assign(this.themeSettings, body);
return this.themeSettings;
}
// --- Account Settings ---
@Get('account')
async getAccount(@Req() req: any) {
const userId = req.user?.sub || req.user?.id;
if (!userId) return { displayName: 'Admin', email: '' };
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) return { displayName: 'Admin', email: '' };
return {
displayName: user.name,
email: user.email,
};
}
@Put('account')
async updateAccount(@Req() req: any, @Body() body: { displayName: string }) {
const userId = req.user?.sub || req.user?.id;
if (!userId) throw new NotFoundException('User not found');
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) throw new NotFoundException('User not found');
user.name = body.displayName;
const saved = await this.userRepository.save(user);
return { displayName: saved.name, email: saved.email };
}
@Put('account/password')
async changePassword(
@Req() req: any,
@Body() body: { currentPassword: string; newPassword: string },
) {
const userId = req.user?.sub || req.user?.id;
if (!userId) throw new NotFoundException('User not found');
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) throw new NotFoundException('User not found');
const valid = await bcrypt.compare(body.currentPassword, user.passwordHash);
if (!valid) {
return { success: false, message: 'Current password is incorrect' };
}
user.passwordHash = await bcrypt.hash(body.newPassword, 12);
await this.userRepository.save(user);
return { success: true };
}
}

View File

@ -0,0 +1,84 @@
import { Controller, Get, Query } from '@nestjs/common';
import { MetricSnapshotRepository } from '../../../infrastructure/repositories/metric-snapshot.repository';
import { HealthCheckResultRepository } from '../../../infrastructure/repositories/health-check-result.repository';
@Controller('api/v1/monitor/metrics')
export class MetricsController {
constructor(
private readonly metricSnapshotRepo: MetricSnapshotRepository,
private readonly healthCheckResultRepo: HealthCheckResultRepository,
) {}
@Get('overview')
async getOverview() {
// Aggregate metrics from recent snapshots
const recentHealthChecks = await this.healthCheckResultRepo.findRecent();
const serverMap = new Map<string, { status: string; host?: string }>();
for (const hc of recentHealthChecks) {
if (!serverMap.has(hc.serverId)) {
serverMap.set(hc.serverId, {
status: hc.status === 'healthy' ? 'online' : 'offline',
host: hc.serverHost,
});
}
}
const totalServers = serverMap.size || 0;
const onlineCount = [...serverMap.values()].filter((s) => s.status === 'online').length;
const onlinePercent = totalServers > 0 ? Math.round((onlineCount / totalServers) * 100) : 0;
return {
data: {
totalServers,
onlinePercent,
avgCpuPercent: 0,
avgMemoryPercent: 0,
totalAlertsToday: 0,
},
};
}
@Get('servers')
async getServerMetrics(
@Query('environment') environment?: string,
@Query('status') status?: string,
@Query('search') search?: string,
) {
const recentHealthChecks = await this.healthCheckResultRepo.findRecent();
// Group by serverId, taking the latest check per server
const serverLatest = new Map<string, any>();
for (const hc of recentHealthChecks) {
const existing = serverLatest.get(hc.serverId);
if (!existing || new Date(hc.checkedAt) > new Date(existing.checkedAt)) {
serverLatest.set(hc.serverId, hc);
}
}
let data = [...serverLatest.values()].map((hc) => ({
id: hc.serverId,
hostname: hc.serverHost || hc.serverId.slice(0, 8),
environment: 'prod' as const,
status: hc.status === 'healthy' ? 'online' : 'offline',
cpuPercent: 0,
memoryPercent: 0,
diskPercent: 0,
lastCheckedAt: hc.checkedAt?.toISOString?.() ?? new Date().toISOString(),
}));
// Apply filters
if (status && status !== 'all') {
data = data.filter((s) => s.status === status);
}
if (environment && environment !== 'all') {
data = data.filter((s) => s.environment === environment);
}
if (search) {
const q = search.toLowerCase();
data = data.filter((s) => s.hostname.toLowerCase().includes(q));
}
return { data, total: data.length };
}
}

View File

@ -7,6 +7,7 @@ import { DatabaseModule } from '@it0/database';
import { RedisEventBus } from '@it0/events';
import { HealthCheckController } from './interfaces/rest/controllers/health-check.controller';
import { AlertController } from './interfaces/rest/controllers/alert.controller';
import { MetricsController } from './interfaces/rest/controllers/metrics.controller';
import { AlertRuleRepository } from './infrastructure/repositories/alert-rule.repository';
import { AlertEventRepository } from './infrastructure/repositories/alert-event.repository';
import { MetricSnapshotRepository } from './infrastructure/repositories/metric-snapshot.repository';
@ -29,7 +30,7 @@ import { MetricSnapshot } from './domain/entities/metric-snapshot.entity';
ScheduleModule.forRoot(),
HttpModule.register({ timeout: 10_000 }),
],
controllers: [HealthCheckController, AlertController],
controllers: [HealthCheckController, AlertController, MetricsController],
providers: [
AlertRuleRepository,
AlertEventRepository,