diff --git a/backend/prisma/migrations/20260314000000_multi_user_isolation/migration.sql b/backend/prisma/migrations/20260314000000_multi_user_isolation/migration.sql new file mode 100644 index 0000000..d0d6b8f --- /dev/null +++ b/backend/prisma/migrations/20260314000000_multi_user_isolation/migration.sql @@ -0,0 +1,36 @@ +-- Delete duplicate admin users first (keep the one with lowest id) +DELETE FROM "users" WHERE "email" = 'admin@wraith.local' AND "id" != (SELECT MIN("id") FROM "users" WHERE "email" = 'admin@wraith.local'); + +-- Add role to users +ALTER TABLE "users" ADD COLUMN "role" TEXT NOT NULL DEFAULT 'user'; + +-- Backfill admin@wraith.local as admin +UPDATE "users" SET "role" = 'admin' WHERE "email" = 'admin@wraith.local'; + +-- Add user_id to all data tables +ALTER TABLE "hosts" ADD COLUMN "user_id" INTEGER; +ALTER TABLE "host_groups" ADD COLUMN "user_id" INTEGER; +ALTER TABLE "credentials" ADD COLUMN "user_id" INTEGER; +ALTER TABLE "ssh_keys" ADD COLUMN "user_id" INTEGER; +ALTER TABLE "connection_logs" ADD COLUMN "user_id" INTEGER; + +-- Backfill existing data to the admin user +UPDATE "hosts" SET "user_id" = (SELECT "id" FROM "users" WHERE "email" = 'admin@wraith.local'); +UPDATE "host_groups" SET "user_id" = (SELECT "id" FROM "users" WHERE "email" = 'admin@wraith.local'); +UPDATE "credentials" SET "user_id" = (SELECT "id" FROM "users" WHERE "email" = 'admin@wraith.local'); +UPDATE "ssh_keys" SET "user_id" = (SELECT "id" FROM "users" WHERE "email" = 'admin@wraith.local'); +UPDATE "connection_logs" SET "user_id" = (SELECT "id" FROM "users" WHERE "email" = 'admin@wraith.local'); + +-- Make user_id NOT NULL after backfill +ALTER TABLE "hosts" ALTER COLUMN "user_id" SET NOT NULL; +ALTER TABLE "host_groups" ALTER COLUMN "user_id" SET NOT NULL; +ALTER TABLE "credentials" ALTER COLUMN "user_id" SET NOT NULL; +ALTER TABLE "ssh_keys" ALTER COLUMN "user_id" SET NOT NULL; +ALTER TABLE "connection_logs" ALTER COLUMN "user_id" SET NOT NULL; + +-- Add foreign keys +ALTER TABLE "hosts" ADD CONSTRAINT "hosts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE; +ALTER TABLE "host_groups" ADD CONSTRAINT "host_groups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE; +ALTER TABLE "credentials" ADD CONSTRAINT "credentials_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE; +ALTER TABLE "ssh_keys" ADD CONSTRAINT "ssh_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE; +ALTER TABLE "connection_logs" ADD CONSTRAINT "connection_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 1b03bce..8c69314 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -8,14 +8,20 @@ datasource db { } model User { - id Int @id @default(autoincrement()) - email String @unique - passwordHash String @map("password_hash") - displayName String? @map("display_name") - totpSecret String? @map("totp_secret") - totpEnabled Boolean @default(false) @map("totp_enabled") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + email String @unique + passwordHash String @map("password_hash") + displayName String? @map("display_name") + role String @default("user") + totpSecret String? @map("totp_secret") + totpEnabled Boolean @default(false) @map("totp_enabled") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + hosts Host[] + hostGroups HostGroup[] + credentials Credential[] + sshKeys SshKey[] + connectionLogs ConnectionLog[] @@map("users") } @@ -24,10 +30,12 @@ model HostGroup { id Int @id @default(autoincrement()) name String parentId Int? @map("parent_id") + userId Int @map("user_id") sortOrder Int @default(0) @map("sort_order") parent HostGroup? @relation("GroupTree", fields: [parentId], references: [id], onDelete: SetNull) children HostGroup[] @relation("GroupTree") hosts Host[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -42,6 +50,7 @@ model Host { protocol Protocol @default(ssh) groupId Int? @map("group_id") credentialId Int? @map("credential_id") + userId Int @map("user_id") tags String[] @default([]) notes String? color String? @db.VarChar(7) @@ -50,6 +59,7 @@ model Host { lastConnectedAt DateTime? @map("last_connected_at") group HostGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) credential Credential? @relation(fields: [credentialId], references: [id], onDelete: SetNull) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) connectionLogs ConnectionLog[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -63,9 +73,11 @@ model Credential { username String? domain String? type CredentialType + userId Int @map("user_id") encryptedValue String? @map("encrypted_value") sshKeyId Int? @map("ssh_key_id") sshKey SshKey? @relation(fields: [sshKeyId], references: [id], onDelete: SetNull) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) hosts Host[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -79,8 +91,10 @@ model SshKey { keyType String @map("key_type") @db.VarChar(20) fingerprint String? publicKey String? @map("public_key") + userId Int @map("user_id") encryptedPrivateKey String @map("encrypted_private_key") passphraseEncrypted String? @map("passphrase_encrypted") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) credentials Credential[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -91,10 +105,12 @@ model SshKey { model ConnectionLog { id Int @id @default(autoincrement()) hostId Int @map("host_id") + userId Int @map("user_id") protocol Protocol connectedAt DateTime @default(now()) @map("connected_at") disconnectedAt DateTime? @map("disconnected_at") host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("connection_logs") } diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index ae814b9..334e653 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -12,9 +12,10 @@ async function main() { email: 'admin@wraith.local', passwordHash: hash, displayName: 'Admin', + role: 'admin', }, }); - console.log('Seed complete: admin@wraith.local / wraith'); + console.log('Seed complete: admin@wraith.local / wraith (role: admin)'); } main() diff --git a/backend/seed.js b/backend/seed.js index 041aee8..39df812 100644 --- a/backend/seed.js +++ b/backend/seed.js @@ -4,9 +4,9 @@ const bcrypt = require('bcrypt'); const prisma = new PrismaClient(); async function main() { - const userCount = await prisma.user.count(); - if (userCount > 0) { - console.log('Seed: users already exist, skipping'); + const admin = await prisma.user.findUnique({ where: { email: 'admin@wraith.local' } }); + if (admin) { + console.log('Seed: admin@wraith.local already exists, skipping'); return; } const hash = await bcrypt.hash('wraith', 10); @@ -15,9 +15,10 @@ async function main() { email: 'admin@wraith.local', passwordHash: hash, displayName: 'Admin', + role: 'admin', }, }); - console.log('Seed complete: admin@wraith.local / wraith'); + console.log('Seed complete: admin@wraith.local / wraith (role: admin)'); } main() diff --git a/backend/src/auth/admin.guard.ts b/backend/src/auth/admin.guard.ts new file mode 100644 index 0000000..7d58269 --- /dev/null +++ b/backend/src/auth/admin.guard.ts @@ -0,0 +1,12 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; + +@Injectable() +export class AdminGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const req = context.switchToHttp().getRequest(); + if (req.user?.role !== 'admin') { + throw new ForbiddenException('Admin access required'); + } + return true; + } +} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index a06eb72..dd89473 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Post, Get, Put, Body, Request, UseGuards, InternalServerErrorException } from '@nestjs/common'; +import { Controller, Post, Get, Put, Delete, Body, Param, Request, UseGuards, ParseIntPipe, InternalServerErrorException } from '@nestjs/common'; import { AuthService } from './auth.service'; import { JwtAuthGuard } from './jwt-auth.guard'; +import { AdminGuard } from './admin.guard'; import { LoginDto } from './dto/login.dto'; import { UpdateProfileDto } from './dto/update-profile.dto'; @@ -48,4 +49,42 @@ export class AuthController { totpDisable(@Request() req: any, @Body() body: { password: string }) { return this.auth.totpDisable(req.user.sub, body.password); } + + // ─── Admin User Management ────────────────────────────────────────────── + + @UseGuards(JwtAuthGuard, AdminGuard) + @Get('users') + listUsers() { + return this.auth.listUsers(); + } + + @UseGuards(JwtAuthGuard, AdminGuard) + @Post('users') + createUser(@Body() body: { email: string; password: string; displayName?: string; role?: string }) { + return this.auth.createUser(body); + } + + @UseGuards(JwtAuthGuard, AdminGuard) + @Put('users/:id') + updateUser(@Param('id', ParseIntPipe) id: number, @Request() req: any, @Body() body: { email?: string; displayName?: string; role?: string }) { + return this.auth.adminUpdateUser(id, req.user.sub, body); + } + + @UseGuards(JwtAuthGuard, AdminGuard) + @Post('users/:id/reset-password') + resetPassword(@Param('id', ParseIntPipe) id: number, @Body() body: { password: string }) { + return this.auth.adminResetPassword(id, body.password); + } + + @UseGuards(JwtAuthGuard, AdminGuard) + @Post('users/:id/reset-totp') + resetTotp(@Param('id', ParseIntPipe) id: number) { + return this.auth.adminResetTotp(id); + } + + @UseGuards(JwtAuthGuard, AdminGuard) + @Delete('users/:id') + deleteUser(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + return this.auth.adminDeleteUser(id, req.user.sub); + } } diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 740c729..04ff422 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -5,6 +5,7 @@ import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { JwtStrategy } from './jwt.strategy'; import { WsAuthGuard } from './ws-auth.guard'; +import { AdminGuard } from './admin.guard'; @Module({ imports: [ @@ -14,8 +15,8 @@ import { WsAuthGuard } from './ws-auth.guard'; signOptions: { expiresIn: '7d' }, }), ], - providers: [AuthService, JwtStrategy, WsAuthGuard], + providers: [AuthService, JwtStrategy, WsAuthGuard, AdminGuard], controllers: [AuthController], - exports: [WsAuthGuard, JwtModule], + exports: [WsAuthGuard, AdminGuard, JwtModule], }) export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 1d1b295..1991f09 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,4 +1,4 @@ -import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common'; +import { Injectable, UnauthorizedException, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { PrismaService } from '../prisma/prisma.service'; import * as bcrypt from 'bcrypt'; @@ -34,10 +34,10 @@ export class AuthService { if (!verified) throw new UnauthorizedException('Invalid TOTP code'); } - const payload = { sub: user.id, email: user.email }; + const payload = { sub: user.id, email: user.email, role: user.role }; return { access_token: this.jwt.sign(payload), - user: { id: user.id, email: user.email, displayName: user.displayName }, + user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role }, }; } @@ -48,6 +48,7 @@ export class AuthService { id: user.id, email: user.email, displayName: user.displayName, + role: user.role, totpEnabled: user.totpEnabled, }; } @@ -140,4 +141,81 @@ export class AuthService { return { message: 'TOTP disabled' }; } + + // ─── Admin User Management ────────────────────────────────────────────── + + async listUsers() { + return this.prisma.user.findMany({ + select: { id: true, email: true, displayName: true, role: true, totpEnabled: true, createdAt: true }, + orderBy: { createdAt: 'asc' }, + }); + } + + async createUser(data: { email: string; password: string; displayName?: string; role?: string }) { + const existing = await this.prisma.user.findUnique({ where: { email: data.email } }); + if (existing) throw new BadRequestException('Email already in use'); + + const hash = await bcrypt.hash(data.password, 10); + const user = await this.prisma.user.create({ + data: { + email: data.email, + passwordHash: hash, + displayName: data.displayName || null, + role: data.role || 'user', + }, + }); + return { id: user.id, email: user.email, displayName: user.displayName, role: user.role }; + } + + async adminUpdateUser(targetId: number, adminId: number, data: { email?: string; displayName?: string; role?: string }) { + const target = await this.prisma.user.findUnique({ where: { id: targetId } }); + if (!target) throw new NotFoundException('User not found'); + + // Prevent removing your own admin role + if (targetId === adminId && data.role && data.role !== 'admin') { + throw new ForbiddenException('Cannot remove your own admin role'); + } + + return this.prisma.user.update({ + where: { id: targetId }, + data: { + email: data.email, + displayName: data.displayName, + role: data.role, + }, + select: { id: true, email: true, displayName: true, role: true }, + }); + } + + async adminResetPassword(targetId: number, newPassword: string) { + const target = await this.prisma.user.findUnique({ where: { id: targetId } }); + if (!target) throw new NotFoundException('User not found'); + + const hash = await bcrypt.hash(newPassword, 10); + await this.prisma.user.update({ where: { id: targetId }, data: { passwordHash: hash } }); + return { message: 'Password reset' }; + } + + async adminResetTotp(targetId: number) { + const target = await this.prisma.user.findUnique({ where: { id: targetId } }); + if (!target) throw new NotFoundException('User not found'); + + await this.prisma.user.update({ + where: { id: targetId }, + data: { totpEnabled: false, totpSecret: null }, + }); + return { message: 'TOTP reset' }; + } + + async adminDeleteUser(targetId: number, adminId: number) { + if (targetId === adminId) { + throw new ForbiddenException('Cannot delete your own account'); + } + const target = await this.prisma.user.findUnique({ where: { id: targetId } }); + if (!target) throw new NotFoundException('User not found'); + + // CASCADE will delete all their hosts, credentials, keys, logs + await this.prisma.user.delete({ where: { id: targetId } }); + return { message: 'User deleted' }; + } } diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts index 7bd78ae..d26c5ba 100644 --- a/backend/src/auth/jwt.strategy.ts +++ b/backend/src/auth/jwt.strategy.ts @@ -12,7 +12,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - validate(payload: { sub: number; email: string }) { - return { sub: payload.sub, email: payload.email }; + validate(payload: { sub: number; email: string; role: string }) { + return { sub: payload.sub, email: payload.email, role: payload.role }; } } diff --git a/backend/src/connections/groups.controller.ts b/backend/src/connections/groups.controller.ts index 71373ea..0554cf5 100644 --- a/backend/src/connections/groups.controller.ts +++ b/backend/src/connections/groups.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, ParseIntPipe } from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Param, Body, Request, UseGuards, ParseIntPipe } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { GroupsService } from './groups.service'; import { CreateGroupDto } from './dto/create-group.dto'; @@ -10,32 +10,32 @@ export class GroupsController { constructor(private groups: GroupsService) {} @Get() - findAll() { - return this.groups.findAll(); + findAll(@Request() req: any) { + return this.groups.findAll(req.user.sub); } @Get('tree') - findTree() { - return this.groups.findTree(); + findTree(@Request() req: any) { + return this.groups.findTree(req.user.sub); } @Get(':id') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.groups.findOne(id); + findOne(@Request() req: any, @Param('id', ParseIntPipe) id: number) { + return this.groups.findOne(id, req.user.sub); } @Post() - create(@Body() dto: CreateGroupDto) { - return this.groups.create(dto); + create(@Request() req: any, @Body() dto: CreateGroupDto) { + return this.groups.create(req.user.sub, dto); } @Put(':id') - update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateGroupDto) { - return this.groups.update(id, dto); + update(@Request() req: any, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateGroupDto) { + return this.groups.update(id, req.user.sub, dto); } @Delete(':id') - remove(@Param('id', ParseIntPipe) id: number) { - return this.groups.remove(id); + remove(@Request() req: any, @Param('id', ParseIntPipe) id: number) { + return this.groups.remove(id, req.user.sub); } } diff --git a/backend/src/connections/groups.service.ts b/backend/src/connections/groups.service.ts index 458205f..4ae0ceb 100644 --- a/backend/src/connections/groups.service.ts +++ b/backend/src/connections/groups.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateGroupDto } from './dto/create-group.dto'; import { UpdateGroupDto } from './dto/update-group.dto'; @@ -7,16 +7,17 @@ import { UpdateGroupDto } from './dto/update-group.dto'; export class GroupsService { constructor(private prisma: PrismaService) {} - findAll() { + findAll(userId: number) { return this.prisma.hostGroup.findMany({ + where: { userId }, include: { children: true, hosts: { select: { id: true, name: true, protocol: true } } }, orderBy: { sortOrder: 'asc' }, }); } - findTree() { + findTree(userId: number) { return this.prisma.hostGroup.findMany({ - where: { parentId: null }, + where: { parentId: null, userId }, include: { hosts: { orderBy: { sortOrder: 'asc' } }, children: { @@ -35,26 +36,27 @@ export class GroupsService { }); } - async findOne(id: number) { + async findOne(id: number, userId: number) { const group = await this.prisma.hostGroup.findUnique({ where: { id }, include: { hosts: true, children: true }, }); if (!group) throw new NotFoundException(`Group ${id} not found`); + if (group.userId !== userId) throw new ForbiddenException('Access denied'); return group; } - create(dto: CreateGroupDto) { - return this.prisma.hostGroup.create({ data: dto }); + create(userId: number, dto: CreateGroupDto) { + return this.prisma.hostGroup.create({ data: { ...dto, userId } }); } - async update(id: number, dto: UpdateGroupDto) { - await this.findOne(id); + async update(id: number, userId: number, dto: UpdateGroupDto) { + await this.findOne(id, userId); return this.prisma.hostGroup.update({ where: { id }, data: dto }); } - async remove(id: number) { - await this.findOne(id); + async remove(id: number, userId: number) { + await this.findOne(id, userId); return this.prisma.hostGroup.delete({ where: { id } }); } } diff --git a/backend/src/connections/hosts.controller.ts b/backend/src/connections/hosts.controller.ts index 88057f7..acb9c71 100644 --- a/backend/src/connections/hosts.controller.ts +++ b/backend/src/connections/hosts.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, Query, UseGuards, ParseIntPipe } from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Param, Body, Query, Request, UseGuards, ParseIntPipe } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { HostsService } from './hosts.service'; import { CreateHostDto } from './dto/create-host.dto'; @@ -10,32 +10,32 @@ export class HostsController { constructor(private hosts: HostsService) {} @Get() - findAll(@Query('search') search?: string) { - return this.hosts.findAll(search); + findAll(@Request() req: any, @Query('search') search?: string) { + return this.hosts.findAll(req.user.sub, search); } @Get(':id') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.hosts.findOne(id); + findOne(@Request() req: any, @Param('id', ParseIntPipe) id: number) { + return this.hosts.findOne(id, req.user.sub); } @Post() - create(@Body() dto: CreateHostDto) { - return this.hosts.create(dto); + create(@Request() req: any, @Body() dto: CreateHostDto) { + return this.hosts.create(req.user.sub, dto); } @Put(':id') - update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateHostDto) { - return this.hosts.update(id, dto); + update(@Request() req: any, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateHostDto) { + return this.hosts.update(id, req.user.sub, dto); } @Delete(':id') - remove(@Param('id', ParseIntPipe) id: number) { - return this.hosts.remove(id); + remove(@Request() req: any, @Param('id', ParseIntPipe) id: number) { + return this.hosts.remove(id, req.user.sub); } @Post('reorder') - reorder(@Body() body: { ids: number[] }) { - return this.hosts.reorder(body.ids); + reorder(@Request() req: any, @Body() body: { ids: number[] }) { + return this.hosts.reorder(req.user.sub, body.ids); } } diff --git a/backend/src/connections/hosts.service.ts b/backend/src/connections/hosts.service.ts index a807af6..b49d4ce 100644 --- a/backend/src/connections/hosts.service.ts +++ b/backend/src/connections/hosts.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateHostDto } from './dto/create-host.dto'; import { UpdateHostDto } from './dto/update-host.dto'; @@ -7,16 +7,15 @@ import { UpdateHostDto } from './dto/update-host.dto'; export class HostsService { constructor(private prisma: PrismaService) {} - findAll(search?: string) { - const where = search - ? { - OR: [ - { name: { contains: search, mode: 'insensitive' as const } }, - { hostname: { contains: search, mode: 'insensitive' as const } }, - { tags: { has: search } }, - ], - } - : {}; + findAll(userId: number, search?: string) { + const where: any = { userId }; + if (search) { + where.OR = [ + { name: { contains: search, mode: 'insensitive' as const } }, + { hostname: { contains: search, mode: 'insensitive' as const } }, + { tags: { has: search } }, + ]; + } return this.prisma.host.findMany({ where, include: { group: true, credential: { select: { id: true, name: true, type: true } } }, @@ -24,16 +23,19 @@ export class HostsService { }); } - async findOne(id: number) { + async findOne(id: number, userId?: number) { const host = await this.prisma.host.findUnique({ where: { id }, include: { group: true, credential: true }, }); if (!host) throw new NotFoundException(`Host ${id} not found`); + if (userId !== undefined && host.userId !== userId) { + throw new ForbiddenException('Access denied'); + } return host; } - create(dto: CreateHostDto) { + create(userId: number, dto: CreateHostDto) { return this.prisma.host.create({ data: { name: dto.name, @@ -42,6 +44,7 @@ export class HostsService { protocol: dto.protocol ?? 'ssh', groupId: dto.groupId, credentialId: dto.credentialId, + userId, tags: dto.tags ?? [], notes: dto.notes, color: dto.color, @@ -50,13 +53,13 @@ export class HostsService { }); } - async update(id: number, dto: UpdateHostDto) { - await this.findOne(id); // throws if not found + async update(id: number, userId: number, dto: UpdateHostDto) { + await this.findOne(id, userId); return this.prisma.host.update({ where: { id }, data: dto }); } - async remove(id: number) { - await this.findOne(id); + async remove(id: number, userId: number) { + await this.findOne(id, userId); return this.prisma.host.delete({ where: { id } }); } @@ -67,7 +70,11 @@ export class HostsService { }); } - async reorder(ids: number[]) { + async reorder(userId: number, ids: number[]) { + // Verify all hosts belong to user + const hosts = await this.prisma.host.findMany({ where: { id: { in: ids }, userId } }); + if (hosts.length !== ids.length) throw new ForbiddenException('Access denied'); + const updates = ids.map((id, index) => this.prisma.host.update({ where: { id }, data: { sortOrder: index } }), ); diff --git a/backend/src/rdp/rdp.gateway.ts b/backend/src/rdp/rdp.gateway.ts index 4c3d11e..b7a0002 100644 --- a/backend/src/rdp/rdp.gateway.ts +++ b/backend/src/rdp/rdp.gateway.ts @@ -12,6 +12,7 @@ export class RdpGateway { // Maps browser WebSocket client → live guacd TCP socket private clientSockets = new Map(); + private clientUsers = new Map(); constructor( private guacamole: GuacamoleService, @@ -28,6 +29,7 @@ export class RdpGateway { return; } + this.clientUsers.set(client, user); this.logger.log(`RDP WS connected: ${user.email}`); client.on('message', async (raw: Buffer) => { @@ -58,6 +60,7 @@ export class RdpGateway { this.clientSockets.delete(client); this.logger.log('guacd socket destroyed on WS close'); } + this.clientUsers.delete(client); }); } @@ -129,9 +132,10 @@ export class RdpGateway { }); // Connection tracking + const wsUser = this.clientUsers.get(client); this.hosts.touchLastConnected(host.id).catch(() => {}); this.prisma.connectionLog - .create({ data: { hostId: host.id, protocol: 'rdp' } }) + .create({ data: { hostId: host.id, userId: wsUser!.sub, protocol: 'rdp' } }) .catch(() => {}); this.send(client, { type: 'connected', hostId: host.id, hostName: host.name }); diff --git a/backend/src/terminal/ssh-connection.service.ts b/backend/src/terminal/ssh-connection.service.ts index 359a6dd..d5fc621 100644 --- a/backend/src/terminal/ssh-connection.service.ts +++ b/backend/src/terminal/ssh-connection.service.ts @@ -26,6 +26,7 @@ export class SshConnectionService { async connect( hostId: number, + userId: number, onData: (data: string) => void, onClose: (reason: string) => void, onHostKeyVerify: (fingerprint: string, isNew: boolean) => Promise, @@ -71,7 +72,7 @@ export class SshConnectionService { // Update lastConnectedAt and create connection log this.hosts.touchLastConnected(hostId); this.prisma.connectionLog.create({ - data: { hostId, protocol: host.protocol }, + data: { hostId, userId, protocol: host.protocol }, }).catch(() => {}); resolve(sessionId); diff --git a/backend/src/terminal/terminal.gateway.ts b/backend/src/terminal/terminal.gateway.ts index 8f6d11e..dbba200 100644 --- a/backend/src/terminal/terminal.gateway.ts +++ b/backend/src/terminal/terminal.gateway.ts @@ -6,6 +6,7 @@ import { SshConnectionService } from './ssh-connection.service'; export class TerminalGateway { private readonly logger = new Logger(TerminalGateway.name); private clientSessions = new Map(); // ws client → sessionIds + private clientUsers = new Map(); // ws client → user constructor( private ssh: SshConnectionService, @@ -21,6 +22,7 @@ export class TerminalGateway { return; } this.clientSessions.set(client, []); + this.clientUsers.set(client, user); this.logger.log(`[WS] Terminal connected: ${user.email}`); client.on('message', async (raw: Buffer) => { @@ -39,6 +41,7 @@ export class TerminalGateway { const sessions = this.clientSessions.get(client) || []; sessions.forEach((sid) => this.ssh.disconnect(sid)); this.clientSessions.delete(client); + this.clientUsers.delete(client); }); } @@ -46,9 +49,11 @@ export class TerminalGateway { switch (msg.type) { case 'connect': { let sessionId = ''; + const wsUser = this.clientUsers.get(client); try { sessionId = await this.ssh.connect( msg.hostId, + wsUser!.sub, (data) => this.send(client, { type: 'data', sessionId, data }), (reason) => this.send(client, { type: 'disconnected', sessionId, reason }), async (fingerprint, isNew) => { diff --git a/backend/src/vault/credentials.controller.ts b/backend/src/vault/credentials.controller.ts index 1cd323d..7d67c21 100644 --- a/backend/src/vault/credentials.controller.ts +++ b/backend/src/vault/credentials.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, ParseIntPipe } from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Param, Body, Request, UseGuards, ParseIntPipe } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CredentialsService } from './credentials.service'; import { CreateCredentialDto } from './dto/create-credential.dto'; @@ -10,27 +10,27 @@ export class CredentialsController { constructor(private credentials: CredentialsService) {} @Get() - findAll() { - return this.credentials.findAll(); + findAll(@Request() req: any) { + return this.credentials.findAll(req.user.sub); } @Get(':id') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.credentials.findOne(id); + findOne(@Request() req: any, @Param('id', ParseIntPipe) id: number) { + return this.credentials.findOne(id, req.user.sub); } @Post() - create(@Body() dto: CreateCredentialDto) { - return this.credentials.create(dto); + create(@Request() req: any, @Body() dto: CreateCredentialDto) { + return this.credentials.create(req.user.sub, dto); } @Put(':id') - update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateCredentialDto) { - return this.credentials.update(id, dto); + update(@Request() req: any, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateCredentialDto) { + return this.credentials.update(id, req.user.sub, dto); } @Delete(':id') - remove(@Param('id', ParseIntPipe) id: number) { - return this.credentials.remove(id); + remove(@Request() req: any, @Param('id', ParseIntPipe) id: number) { + return this.credentials.remove(id, req.user.sub); } } diff --git a/backend/src/vault/credentials.service.ts b/backend/src/vault/credentials.service.ts index 2741324..eef92b0 100644 --- a/backend/src/vault/credentials.service.ts +++ b/backend/src/vault/credentials.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { EncryptionService } from './encryption.service'; import { CreateCredentialDto } from './dto/create-credential.dto'; @@ -11,23 +11,27 @@ export class CredentialsService { private encryption: EncryptionService, ) {} - findAll() { + findAll(userId: number) { return this.prisma.credential.findMany({ + where: { userId }, include: { sshKey: { select: { id: true, name: true, keyType: true, fingerprint: true } } }, orderBy: { name: 'asc' }, }); } - async findOne(id: number) { + async findOne(id: number, userId?: number) { const cred = await this.prisma.credential.findUnique({ where: { id }, include: { sshKey: true, hosts: { select: { id: true, name: true } } }, }); if (!cred) throw new NotFoundException(`Credential ${id} not found`); + if (userId !== undefined && cred.userId !== userId) { + throw new ForbiddenException('Access denied'); + } return cred; } - create(dto: CreateCredentialDto) { + create(userId: number, dto: CreateCredentialDto) { const encryptedValue = dto.password ? this.encryption.encrypt(dto.password) : null; return this.prisma.credential.create({ data: { @@ -35,14 +39,15 @@ export class CredentialsService { username: dto.username, domain: dto.domain, type: dto.type, + userId, encryptedValue, sshKeyId: dto.sshKeyId, }, }); } - async update(id: number, dto: UpdateCredentialDto) { - await this.findOne(id); + async update(id: number, userId: number, dto: UpdateCredentialDto) { + await this.findOne(id, userId); const data: any = { ...dto }; delete data.password; if (dto.password) { @@ -51,8 +56,8 @@ export class CredentialsService { return this.prisma.credential.update({ where: { id }, data }); } - async remove(id: number) { - await this.findOne(id); + async remove(id: number, userId: number) { + await this.findOne(id, userId); return this.prisma.credential.delete({ where: { id } }); } diff --git a/backend/src/vault/ssh-keys.controller.ts b/backend/src/vault/ssh-keys.controller.ts index ae234e8..0a7627a 100644 --- a/backend/src/vault/ssh-keys.controller.ts +++ b/backend/src/vault/ssh-keys.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, ParseIntPipe } from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Param, Body, Request, UseGuards, ParseIntPipe } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { SshKeysService } from './ssh-keys.service'; import { CreateSshKeyDto } from './dto/create-ssh-key.dto'; @@ -10,27 +10,27 @@ export class SshKeysController { constructor(private sshKeys: SshKeysService) {} @Get() - findAll() { - return this.sshKeys.findAll(); + findAll(@Request() req: any) { + return this.sshKeys.findAll(req.user.sub); } @Get(':id') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.sshKeys.findOne(id); + findOne(@Request() req: any, @Param('id', ParseIntPipe) id: number) { + return this.sshKeys.findOne(id, req.user.sub); } @Post() - create(@Body() dto: CreateSshKeyDto) { - return this.sshKeys.create(dto); + create(@Request() req: any, @Body() dto: CreateSshKeyDto) { + return this.sshKeys.create(req.user.sub, dto); } @Put(':id') - update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateSshKeyDto) { - return this.sshKeys.update(id, dto); + update(@Request() req: any, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateSshKeyDto) { + return this.sshKeys.update(id, req.user.sub, dto); } @Delete(':id') - remove(@Param('id', ParseIntPipe) id: number) { - return this.sshKeys.remove(id); + remove(@Request() req: any, @Param('id', ParseIntPipe) id: number) { + return this.sshKeys.remove(id, req.user.sub); } } diff --git a/backend/src/vault/ssh-keys.service.ts b/backend/src/vault/ssh-keys.service.ts index 71e6c2a..a38794c 100644 --- a/backend/src/vault/ssh-keys.service.ts +++ b/backend/src/vault/ssh-keys.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { EncryptionService } from './encryption.service'; import { CreateSshKeyDto } from './dto/create-ssh-key.dto'; @@ -12,19 +12,23 @@ export class SshKeysService { private encryption: EncryptionService, ) {} - findAll() { + findAll(userId: number) { return this.prisma.sshKey.findMany({ + where: { userId }, select: { id: true, name: true, keyType: true, fingerprint: true, publicKey: true, createdAt: true }, orderBy: { name: 'asc' }, }); } - async findOne(id: number) { + async findOne(id: number, userId?: number) { const key = await this.prisma.sshKey.findUnique({ where: { id }, include: { credentials: { select: { id: true, name: true } } }, }); if (!key) throw new NotFoundException(`SSH key ${id} not found`); + if (userId !== undefined && key.userId !== userId) { + throw new ForbiddenException('Access denied'); + } // Never return encrypted private key over API return { id: key.id, @@ -37,7 +41,7 @@ export class SshKeysService { }; } - async create(dto: CreateSshKeyDto) { + async create(userId: number, dto: CreateSshKeyDto) { // Detect key type from private key content const keyType = this.detectKeyType(dto.privateKey); @@ -56,15 +60,17 @@ export class SshKeysService { keyType, fingerprint, publicKey: dto.publicKey || null, + userId, encryptedPrivateKey, passphraseEncrypted, }, }); } - async update(id: number, dto: UpdateSshKeyDto) { + async update(id: number, userId: number, dto: UpdateSshKeyDto) { const key = await this.prisma.sshKey.findUnique({ where: { id } }); if (!key) throw new NotFoundException(`SSH key ${id} not found`); + if (key.userId !== userId) throw new ForbiddenException('Access denied'); const data: any = {}; if (dto.name) data.name = dto.name; @@ -76,9 +82,10 @@ export class SshKeysService { return this.prisma.sshKey.update({ where: { id }, data }); } - async remove(id: number) { + async remove(id: number, userId: number) { const key = await this.prisma.sshKey.findUnique({ where: { id } }); if (!key) throw new NotFoundException(`SSH key ${id} not found`); + if (key.userId !== userId) throw new ForbiddenException('Access denied'); return this.prisma.sshKey.delete({ where: { id } }); } diff --git a/frontend/stores/auth.store.ts b/frontend/stores/auth.store.ts index a9ed9da..2518e1c 100644 --- a/frontend/stores/auth.store.ts +++ b/frontend/stores/auth.store.ts @@ -4,6 +4,7 @@ interface User { id: number email: string displayName: string | null + role: string totpEnabled?: boolean } @@ -20,6 +21,7 @@ export const useAuthStore = defineStore('auth', { }), getters: { isAuthenticated: (state) => !!state.token, + isAdmin: (state) => state.user?.role === 'admin', }, actions: { async login(email: string, password: string, totpCode?: string): Promise {