diff --git a/packages/services/auth-service/src/auth.module.ts b/packages/services/auth-service/src/auth.module.ts index c538dbd..e93891c 100644 --- a/packages/services/auth-service/src/auth.module.ts +++ b/packages/services/auth-service/src/auth.module.ts @@ -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, diff --git a/packages/services/auth-service/src/interfaces/rest/controllers/permission.controller.ts b/packages/services/auth-service/src/interfaces/rest/controllers/permission.controller.ts new file mode 100644 index 0000000..43e3d85 --- /dev/null +++ b/packages/services/auth-service/src/interfaces/rest/controllers/permission.controller.ts @@ -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, + ) {} + + @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 }; + } +} diff --git a/packages/services/auth-service/src/interfaces/rest/controllers/role.controller.ts b/packages/services/auth-service/src/interfaces/rest/controllers/role.controller.ts new file mode 100644 index 0000000..cd115d1 --- /dev/null +++ b/packages/services/auth-service/src/interfaces/rest/controllers/role.controller.ts @@ -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, + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + @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 }; + } +} diff --git a/packages/services/auth-service/src/interfaces/rest/controllers/settings.controller.ts b/packages/services/auth-service/src/interfaces/rest/controllers/settings.controller.ts new file mode 100644 index 0000000..0673196 --- /dev/null +++ b/packages/services/auth-service/src/interfaces/rest/controllers/settings.controller.ts @@ -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 = { + platformName: 'IT0', + defaultTimezone: 'UTC', + defaultLanguage: 'en', + autoApproveThreshold: 1, + }; + + private notificationSettings: Record = { + emailEnabled: false, + smsEnabled: false, + pushEnabled: true, + defaultEscalationPolicy: 'immediate', + }; + + private themeSettings: Record = { + mode: 'dark', + primaryColor: '#3b82f6', + }; + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectRepository(ApiKey) + private readonly apiKeyRepository: Repository, + ) {} + + // --- 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 }; + } +} diff --git a/packages/services/monitor-service/src/interfaces/rest/controllers/metrics.controller.ts b/packages/services/monitor-service/src/interfaces/rest/controllers/metrics.controller.ts new file mode 100644 index 0000000..4cb2e8d --- /dev/null +++ b/packages/services/monitor-service/src/interfaces/rest/controllers/metrics.controller.ts @@ -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(); + 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(); + 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 }; + } +} diff --git a/packages/services/monitor-service/src/monitor.module.ts b/packages/services/monitor-service/src/monitor.module.ts index 5e6e143..59fbfcf 100644 --- a/packages/services/monitor-service/src/monitor.module.ts +++ b/packages/services/monitor-service/src/monitor.module.ts @@ -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,