feat: multi-user isolation with admin/user roles

Full per-user data isolation across all tables:
- Migration adds userId FK to hosts, host_groups, credentials, ssh_keys,
  connection_logs. Backfills existing data to admin@wraith.local.
- All services scope queries by userId from JWT (req.user.sub).
  Users can only see/modify their own data. Cross-user access returns 403.
- Two roles: admin (full access + user management) and user (own data only).
- Admin endpoints: list/create/edit/delete users, reset password, reset TOTP.
  Protected by AdminGuard. Admins cannot delete themselves or remove own role.
- JWT payload now includes role. Frontend auth store exposes isAdmin getter.
- Seed script fixed: checks for admin@wraith.local specifically (not any user).
  Uses upsert, seeds with role=admin. Migration cleans up duplicate users.
- Connection logs now attributed to the connecting user via WS auth.
- Deleting a user CASCADEs to all their hosts, credentials, keys, and logs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-14 12:57:38 -04:00
parent b749242583
commit 6d76558bc3
21 changed files with 331 additions and 114 deletions

View File

@ -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;

View File

@ -12,10 +12,16 @@ model User {
email String @unique email String @unique
passwordHash String @map("password_hash") passwordHash String @map("password_hash")
displayName String? @map("display_name") displayName String? @map("display_name")
role String @default("user")
totpSecret String? @map("totp_secret") totpSecret String? @map("totp_secret")
totpEnabled Boolean @default(false) @map("totp_enabled") totpEnabled Boolean @default(false) @map("totp_enabled")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
hosts Host[]
hostGroups HostGroup[]
credentials Credential[]
sshKeys SshKey[]
connectionLogs ConnectionLog[]
@@map("users") @@map("users")
} }
@ -24,10 +30,12 @@ model HostGroup {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
parentId Int? @map("parent_id") parentId Int? @map("parent_id")
userId Int @map("user_id")
sortOrder Int @default(0) @map("sort_order") sortOrder Int @default(0) @map("sort_order")
parent HostGroup? @relation("GroupTree", fields: [parentId], references: [id], onDelete: SetNull) parent HostGroup? @relation("GroupTree", fields: [parentId], references: [id], onDelete: SetNull)
children HostGroup[] @relation("GroupTree") children HostGroup[] @relation("GroupTree")
hosts Host[] hosts Host[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@ -42,6 +50,7 @@ model Host {
protocol Protocol @default(ssh) protocol Protocol @default(ssh)
groupId Int? @map("group_id") groupId Int? @map("group_id")
credentialId Int? @map("credential_id") credentialId Int? @map("credential_id")
userId Int @map("user_id")
tags String[] @default([]) tags String[] @default([])
notes String? notes String?
color String? @db.VarChar(7) color String? @db.VarChar(7)
@ -50,6 +59,7 @@ model Host {
lastConnectedAt DateTime? @map("last_connected_at") lastConnectedAt DateTime? @map("last_connected_at")
group HostGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) group HostGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
credential Credential? @relation(fields: [credentialId], references: [id], onDelete: SetNull) credential Credential? @relation(fields: [credentialId], references: [id], onDelete: SetNull)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
connectionLogs ConnectionLog[] connectionLogs ConnectionLog[]
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@ -63,9 +73,11 @@ model Credential {
username String? username String?
domain String? domain String?
type CredentialType type CredentialType
userId Int @map("user_id")
encryptedValue String? @map("encrypted_value") encryptedValue String? @map("encrypted_value")
sshKeyId Int? @map("ssh_key_id") sshKeyId Int? @map("ssh_key_id")
sshKey SshKey? @relation(fields: [sshKeyId], references: [id], onDelete: SetNull) sshKey SshKey? @relation(fields: [sshKeyId], references: [id], onDelete: SetNull)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
hosts Host[] hosts Host[]
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@ -79,8 +91,10 @@ model SshKey {
keyType String @map("key_type") @db.VarChar(20) keyType String @map("key_type") @db.VarChar(20)
fingerprint String? fingerprint String?
publicKey String? @map("public_key") publicKey String? @map("public_key")
userId Int @map("user_id")
encryptedPrivateKey String @map("encrypted_private_key") encryptedPrivateKey String @map("encrypted_private_key")
passphraseEncrypted String? @map("passphrase_encrypted") passphraseEncrypted String? @map("passphrase_encrypted")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
credentials Credential[] credentials Credential[]
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@ -91,10 +105,12 @@ model SshKey {
model ConnectionLog { model ConnectionLog {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
hostId Int @map("host_id") hostId Int @map("host_id")
userId Int @map("user_id")
protocol Protocol protocol Protocol
connectedAt DateTime @default(now()) @map("connected_at") connectedAt DateTime @default(now()) @map("connected_at")
disconnectedAt DateTime? @map("disconnected_at") disconnectedAt DateTime? @map("disconnected_at")
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("connection_logs") @@map("connection_logs")
} }

View File

@ -12,9 +12,10 @@ async function main() {
email: 'admin@wraith.local', email: 'admin@wraith.local',
passwordHash: hash, passwordHash: hash,
displayName: 'Admin', displayName: 'Admin',
role: 'admin',
}, },
}); });
console.log('Seed complete: admin@wraith.local / wraith'); console.log('Seed complete: admin@wraith.local / wraith (role: admin)');
} }
main() main()

View File

@ -4,9 +4,9 @@ const bcrypt = require('bcrypt');
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function main() {
const userCount = await prisma.user.count(); const admin = await prisma.user.findUnique({ where: { email: 'admin@wraith.local' } });
if (userCount > 0) { if (admin) {
console.log('Seed: users already exist, skipping'); console.log('Seed: admin@wraith.local already exists, skipping');
return; return;
} }
const hash = await bcrypt.hash('wraith', 10); const hash = await bcrypt.hash('wraith', 10);
@ -15,9 +15,10 @@ async function main() {
email: 'admin@wraith.local', email: 'admin@wraith.local',
passwordHash: hash, passwordHash: hash,
displayName: 'Admin', displayName: 'Admin',
role: 'admin',
}, },
}); });
console.log('Seed complete: admin@wraith.local / wraith'); console.log('Seed complete: admin@wraith.local / wraith (role: admin)');
} }
main() main()

View File

@ -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;
}
}

View File

@ -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 { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
import { AdminGuard } from './admin.guard';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { UpdateProfileDto } from './dto/update-profile.dto'; import { UpdateProfileDto } from './dto/update-profile.dto';
@ -48,4 +49,42 @@ export class AuthController {
totpDisable(@Request() req: any, @Body() body: { password: string }) { totpDisable(@Request() req: any, @Body() body: { password: string }) {
return this.auth.totpDisable(req.user.sub, body.password); 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);
}
} }

View File

@ -5,6 +5,7 @@ import { AuthService } from './auth.service';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from './jwt.strategy';
import { WsAuthGuard } from './ws-auth.guard'; import { WsAuthGuard } from './ws-auth.guard';
import { AdminGuard } from './admin.guard';
@Module({ @Module({
imports: [ imports: [
@ -14,8 +15,8 @@ import { WsAuthGuard } from './ws-auth.guard';
signOptions: { expiresIn: '7d' }, signOptions: { expiresIn: '7d' },
}), }),
], ],
providers: [AuthService, JwtStrategy, WsAuthGuard], providers: [AuthService, JwtStrategy, WsAuthGuard, AdminGuard],
controllers: [AuthController], controllers: [AuthController],
exports: [WsAuthGuard, JwtModule], exports: [WsAuthGuard, AdminGuard, JwtModule],
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -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 { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
@ -34,10 +34,10 @@ export class AuthService {
if (!verified) throw new UnauthorizedException('Invalid TOTP code'); 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 { return {
access_token: this.jwt.sign(payload), 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, id: user.id,
email: user.email, email: user.email,
displayName: user.displayName, displayName: user.displayName,
role: user.role,
totpEnabled: user.totpEnabled, totpEnabled: user.totpEnabled,
}; };
} }
@ -140,4 +141,81 @@ export class AuthService {
return { message: 'TOTP disabled' }; 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' };
}
} }

View File

@ -12,7 +12,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}); });
} }
validate(payload: { sub: number; email: string }) { validate(payload: { sub: number; email: string; role: string }) {
return { sub: payload.sub, email: payload.email }; return { sub: payload.sub, email: payload.email, role: payload.role };
} }
} }

View File

@ -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 { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { GroupsService } from './groups.service'; import { GroupsService } from './groups.service';
import { CreateGroupDto } from './dto/create-group.dto'; import { CreateGroupDto } from './dto/create-group.dto';
@ -10,32 +10,32 @@ export class GroupsController {
constructor(private groups: GroupsService) {} constructor(private groups: GroupsService) {}
@Get() @Get()
findAll() { findAll(@Request() req: any) {
return this.groups.findAll(); return this.groups.findAll(req.user.sub);
} }
@Get('tree') @Get('tree')
findTree() { findTree(@Request() req: any) {
return this.groups.findTree(); return this.groups.findTree(req.user.sub);
} }
@Get(':id') @Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
return this.groups.findOne(id); return this.groups.findOne(id, req.user.sub);
} }
@Post() @Post()
create(@Body() dto: CreateGroupDto) { create(@Request() req: any, @Body() dto: CreateGroupDto) {
return this.groups.create(dto); return this.groups.create(req.user.sub, dto);
} }
@Put(':id') @Put(':id')
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateGroupDto) { update(@Request() req: any, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateGroupDto) {
return this.groups.update(id, dto); return this.groups.update(id, req.user.sub, dto);
} }
@Delete(':id') @Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) { remove(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
return this.groups.remove(id); return this.groups.remove(id, req.user.sub);
} }
} }

View File

@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateGroupDto } from './dto/create-group.dto'; import { CreateGroupDto } from './dto/create-group.dto';
import { UpdateGroupDto } from './dto/update-group.dto'; import { UpdateGroupDto } from './dto/update-group.dto';
@ -7,16 +7,17 @@ import { UpdateGroupDto } from './dto/update-group.dto';
export class GroupsService { export class GroupsService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
findAll() { findAll(userId: number) {
return this.prisma.hostGroup.findMany({ return this.prisma.hostGroup.findMany({
where: { userId },
include: { children: true, hosts: { select: { id: true, name: true, protocol: true } } }, include: { children: true, hosts: { select: { id: true, name: true, protocol: true } } },
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },
}); });
} }
findTree() { findTree(userId: number) {
return this.prisma.hostGroup.findMany({ return this.prisma.hostGroup.findMany({
where: { parentId: null }, where: { parentId: null, userId },
include: { include: {
hosts: { orderBy: { sortOrder: 'asc' } }, hosts: { orderBy: { sortOrder: 'asc' } },
children: { 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({ const group = await this.prisma.hostGroup.findUnique({
where: { id }, where: { id },
include: { hosts: true, children: true }, include: { hosts: true, children: true },
}); });
if (!group) throw new NotFoundException(`Group ${id} not found`); if (!group) throw new NotFoundException(`Group ${id} not found`);
if (group.userId !== userId) throw new ForbiddenException('Access denied');
return group; return group;
} }
create(dto: CreateGroupDto) { create(userId: number, dto: CreateGroupDto) {
return this.prisma.hostGroup.create({ data: dto }); return this.prisma.hostGroup.create({ data: { ...dto, userId } });
} }
async update(id: number, dto: UpdateGroupDto) { async update(id: number, userId: number, dto: UpdateGroupDto) {
await this.findOne(id); await this.findOne(id, userId);
return this.prisma.hostGroup.update({ where: { id }, data: dto }); return this.prisma.hostGroup.update({ where: { id }, data: dto });
} }
async remove(id: number) { async remove(id: number, userId: number) {
await this.findOne(id); await this.findOne(id, userId);
return this.prisma.hostGroup.delete({ where: { id } }); return this.prisma.hostGroup.delete({ where: { id } });
} }
} }

View File

@ -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 { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { HostsService } from './hosts.service'; import { HostsService } from './hosts.service';
import { CreateHostDto } from './dto/create-host.dto'; import { CreateHostDto } from './dto/create-host.dto';
@ -10,32 +10,32 @@ export class HostsController {
constructor(private hosts: HostsService) {} constructor(private hosts: HostsService) {}
@Get() @Get()
findAll(@Query('search') search?: string) { findAll(@Request() req: any, @Query('search') search?: string) {
return this.hosts.findAll(search); return this.hosts.findAll(req.user.sub, search);
} }
@Get(':id') @Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
return this.hosts.findOne(id); return this.hosts.findOne(id, req.user.sub);
} }
@Post() @Post()
create(@Body() dto: CreateHostDto) { create(@Request() req: any, @Body() dto: CreateHostDto) {
return this.hosts.create(dto); return this.hosts.create(req.user.sub, dto);
} }
@Put(':id') @Put(':id')
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateHostDto) { update(@Request() req: any, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateHostDto) {
return this.hosts.update(id, dto); return this.hosts.update(id, req.user.sub, dto);
} }
@Delete(':id') @Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) { remove(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
return this.hosts.remove(id); return this.hosts.remove(id, req.user.sub);
} }
@Post('reorder') @Post('reorder')
reorder(@Body() body: { ids: number[] }) { reorder(@Request() req: any, @Body() body: { ids: number[] }) {
return this.hosts.reorder(body.ids); return this.hosts.reorder(req.user.sub, body.ids);
} }
} }

View File

@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateHostDto } from './dto/create-host.dto'; import { CreateHostDto } from './dto/create-host.dto';
import { UpdateHostDto } from './dto/update-host.dto'; import { UpdateHostDto } from './dto/update-host.dto';
@ -7,16 +7,15 @@ import { UpdateHostDto } from './dto/update-host.dto';
export class HostsService { export class HostsService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
findAll(search?: string) { findAll(userId: number, search?: string) {
const where = search const where: any = { userId };
? { if (search) {
OR: [ where.OR = [
{ name: { contains: search, mode: 'insensitive' as const } }, { name: { contains: search, mode: 'insensitive' as const } },
{ hostname: { contains: search, mode: 'insensitive' as const } }, { hostname: { contains: search, mode: 'insensitive' as const } },
{ tags: { has: search } }, { tags: { has: search } },
], ];
} }
: {};
return this.prisma.host.findMany({ return this.prisma.host.findMany({
where, where,
include: { group: true, credential: { select: { id: true, name: true, type: true } } }, 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({ const host = await this.prisma.host.findUnique({
where: { id }, where: { id },
include: { group: true, credential: true }, include: { group: true, credential: true },
}); });
if (!host) throw new NotFoundException(`Host ${id} not found`); if (!host) throw new NotFoundException(`Host ${id} not found`);
if (userId !== undefined && host.userId !== userId) {
throw new ForbiddenException('Access denied');
}
return host; return host;
} }
create(dto: CreateHostDto) { create(userId: number, dto: CreateHostDto) {
return this.prisma.host.create({ return this.prisma.host.create({
data: { data: {
name: dto.name, name: dto.name,
@ -42,6 +44,7 @@ export class HostsService {
protocol: dto.protocol ?? 'ssh', protocol: dto.protocol ?? 'ssh',
groupId: dto.groupId, groupId: dto.groupId,
credentialId: dto.credentialId, credentialId: dto.credentialId,
userId,
tags: dto.tags ?? [], tags: dto.tags ?? [],
notes: dto.notes, notes: dto.notes,
color: dto.color, color: dto.color,
@ -50,13 +53,13 @@ export class HostsService {
}); });
} }
async update(id: number, dto: UpdateHostDto) { async update(id: number, userId: number, dto: UpdateHostDto) {
await this.findOne(id); // throws if not found await this.findOne(id, userId);
return this.prisma.host.update({ where: { id }, data: dto }); return this.prisma.host.update({ where: { id }, data: dto });
} }
async remove(id: number) { async remove(id: number, userId: number) {
await this.findOne(id); await this.findOne(id, userId);
return this.prisma.host.delete({ where: { id } }); 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) => const updates = ids.map((id, index) =>
this.prisma.host.update({ where: { id }, data: { sortOrder: index } }), this.prisma.host.update({ where: { id }, data: { sortOrder: index } }),
); );

View File

@ -12,6 +12,7 @@ export class RdpGateway {
// Maps browser WebSocket client → live guacd TCP socket // Maps browser WebSocket client → live guacd TCP socket
private clientSockets = new Map<any, net.Socket>(); private clientSockets = new Map<any, net.Socket>();
private clientUsers = new Map<any, { sub: number; email: string }>();
constructor( constructor(
private guacamole: GuacamoleService, private guacamole: GuacamoleService,
@ -28,6 +29,7 @@ export class RdpGateway {
return; return;
} }
this.clientUsers.set(client, user);
this.logger.log(`RDP WS connected: ${user.email}`); this.logger.log(`RDP WS connected: ${user.email}`);
client.on('message', async (raw: Buffer) => { client.on('message', async (raw: Buffer) => {
@ -58,6 +60,7 @@ export class RdpGateway {
this.clientSockets.delete(client); this.clientSockets.delete(client);
this.logger.log('guacd socket destroyed on WS close'); this.logger.log('guacd socket destroyed on WS close');
} }
this.clientUsers.delete(client);
}); });
} }
@ -129,9 +132,10 @@ export class RdpGateway {
}); });
// Connection tracking // Connection tracking
const wsUser = this.clientUsers.get(client);
this.hosts.touchLastConnected(host.id).catch(() => {}); this.hosts.touchLastConnected(host.id).catch(() => {});
this.prisma.connectionLog this.prisma.connectionLog
.create({ data: { hostId: host.id, protocol: 'rdp' } }) .create({ data: { hostId: host.id, userId: wsUser!.sub, protocol: 'rdp' } })
.catch(() => {}); .catch(() => {});
this.send(client, { type: 'connected', hostId: host.id, hostName: host.name }); this.send(client, { type: 'connected', hostId: host.id, hostName: host.name });

View File

@ -26,6 +26,7 @@ export class SshConnectionService {
async connect( async connect(
hostId: number, hostId: number,
userId: number,
onData: (data: string) => void, onData: (data: string) => void,
onClose: (reason: string) => void, onClose: (reason: string) => void,
onHostKeyVerify: (fingerprint: string, isNew: boolean) => Promise<boolean>, onHostKeyVerify: (fingerprint: string, isNew: boolean) => Promise<boolean>,
@ -71,7 +72,7 @@ export class SshConnectionService {
// Update lastConnectedAt and create connection log // Update lastConnectedAt and create connection log
this.hosts.touchLastConnected(hostId); this.hosts.touchLastConnected(hostId);
this.prisma.connectionLog.create({ this.prisma.connectionLog.create({
data: { hostId, protocol: host.protocol }, data: { hostId, userId, protocol: host.protocol },
}).catch(() => {}); }).catch(() => {});
resolve(sessionId); resolve(sessionId);

View File

@ -6,6 +6,7 @@ import { SshConnectionService } from './ssh-connection.service';
export class TerminalGateway { export class TerminalGateway {
private readonly logger = new Logger(TerminalGateway.name); private readonly logger = new Logger(TerminalGateway.name);
private clientSessions = new Map<any, string[]>(); // ws client → sessionIds private clientSessions = new Map<any, string[]>(); // ws client → sessionIds
private clientUsers = new Map<any, { sub: number; email: string }>(); // ws client → user
constructor( constructor(
private ssh: SshConnectionService, private ssh: SshConnectionService,
@ -21,6 +22,7 @@ export class TerminalGateway {
return; return;
} }
this.clientSessions.set(client, []); this.clientSessions.set(client, []);
this.clientUsers.set(client, user);
this.logger.log(`[WS] Terminal connected: ${user.email}`); this.logger.log(`[WS] Terminal connected: ${user.email}`);
client.on('message', async (raw: Buffer) => { client.on('message', async (raw: Buffer) => {
@ -39,6 +41,7 @@ export class TerminalGateway {
const sessions = this.clientSessions.get(client) || []; const sessions = this.clientSessions.get(client) || [];
sessions.forEach((sid) => this.ssh.disconnect(sid)); sessions.forEach((sid) => this.ssh.disconnect(sid));
this.clientSessions.delete(client); this.clientSessions.delete(client);
this.clientUsers.delete(client);
}); });
} }
@ -46,9 +49,11 @@ export class TerminalGateway {
switch (msg.type) { switch (msg.type) {
case 'connect': { case 'connect': {
let sessionId = ''; let sessionId = '';
const wsUser = this.clientUsers.get(client);
try { try {
sessionId = await this.ssh.connect( sessionId = await this.ssh.connect(
msg.hostId, msg.hostId,
wsUser!.sub,
(data) => this.send(client, { type: 'data', sessionId, data }), (data) => this.send(client, { type: 'data', sessionId, data }),
(reason) => this.send(client, { type: 'disconnected', sessionId, reason }), (reason) => this.send(client, { type: 'disconnected', sessionId, reason }),
async (fingerprint, isNew) => { async (fingerprint, isNew) => {

View File

@ -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 { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CredentialsService } from './credentials.service'; import { CredentialsService } from './credentials.service';
import { CreateCredentialDto } from './dto/create-credential.dto'; import { CreateCredentialDto } from './dto/create-credential.dto';
@ -10,27 +10,27 @@ export class CredentialsController {
constructor(private credentials: CredentialsService) {} constructor(private credentials: CredentialsService) {}
@Get() @Get()
findAll() { findAll(@Request() req: any) {
return this.credentials.findAll(); return this.credentials.findAll(req.user.sub);
} }
@Get(':id') @Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
return this.credentials.findOne(id); return this.credentials.findOne(id, req.user.sub);
} }
@Post() @Post()
create(@Body() dto: CreateCredentialDto) { create(@Request() req: any, @Body() dto: CreateCredentialDto) {
return this.credentials.create(dto); return this.credentials.create(req.user.sub, dto);
} }
@Put(':id') @Put(':id')
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateCredentialDto) { update(@Request() req: any, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateCredentialDto) {
return this.credentials.update(id, dto); return this.credentials.update(id, req.user.sub, dto);
} }
@Delete(':id') @Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) { remove(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
return this.credentials.remove(id); return this.credentials.remove(id, req.user.sub);
} }
} }

View File

@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { EncryptionService } from './encryption.service'; import { EncryptionService } from './encryption.service';
import { CreateCredentialDto } from './dto/create-credential.dto'; import { CreateCredentialDto } from './dto/create-credential.dto';
@ -11,23 +11,27 @@ export class CredentialsService {
private encryption: EncryptionService, private encryption: EncryptionService,
) {} ) {}
findAll() { findAll(userId: number) {
return this.prisma.credential.findMany({ return this.prisma.credential.findMany({
where: { userId },
include: { sshKey: { select: { id: true, name: true, keyType: true, fingerprint: true } } }, include: { sshKey: { select: { id: true, name: true, keyType: true, fingerprint: true } } },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
} }
async findOne(id: number) { async findOne(id: number, userId?: number) {
const cred = await this.prisma.credential.findUnique({ const cred = await this.prisma.credential.findUnique({
where: { id }, where: { id },
include: { sshKey: true, hosts: { select: { id: true, name: true } } }, include: { sshKey: true, hosts: { select: { id: true, name: true } } },
}); });
if (!cred) throw new NotFoundException(`Credential ${id} not found`); if (!cred) throw new NotFoundException(`Credential ${id} not found`);
if (userId !== undefined && cred.userId !== userId) {
throw new ForbiddenException('Access denied');
}
return cred; return cred;
} }
create(dto: CreateCredentialDto) { create(userId: number, dto: CreateCredentialDto) {
const encryptedValue = dto.password ? this.encryption.encrypt(dto.password) : null; const encryptedValue = dto.password ? this.encryption.encrypt(dto.password) : null;
return this.prisma.credential.create({ return this.prisma.credential.create({
data: { data: {
@ -35,14 +39,15 @@ export class CredentialsService {
username: dto.username, username: dto.username,
domain: dto.domain, domain: dto.domain,
type: dto.type, type: dto.type,
userId,
encryptedValue, encryptedValue,
sshKeyId: dto.sshKeyId, sshKeyId: dto.sshKeyId,
}, },
}); });
} }
async update(id: number, dto: UpdateCredentialDto) { async update(id: number, userId: number, dto: UpdateCredentialDto) {
await this.findOne(id); await this.findOne(id, userId);
const data: any = { ...dto }; const data: any = { ...dto };
delete data.password; delete data.password;
if (dto.password) { if (dto.password) {
@ -51,8 +56,8 @@ export class CredentialsService {
return this.prisma.credential.update({ where: { id }, data }); return this.prisma.credential.update({ where: { id }, data });
} }
async remove(id: number) { async remove(id: number, userId: number) {
await this.findOne(id); await this.findOne(id, userId);
return this.prisma.credential.delete({ where: { id } }); return this.prisma.credential.delete({ where: { id } });
} }

View File

@ -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 { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { SshKeysService } from './ssh-keys.service'; import { SshKeysService } from './ssh-keys.service';
import { CreateSshKeyDto } from './dto/create-ssh-key.dto'; import { CreateSshKeyDto } from './dto/create-ssh-key.dto';
@ -10,27 +10,27 @@ export class SshKeysController {
constructor(private sshKeys: SshKeysService) {} constructor(private sshKeys: SshKeysService) {}
@Get() @Get()
findAll() { findAll(@Request() req: any) {
return this.sshKeys.findAll(); return this.sshKeys.findAll(req.user.sub);
} }
@Get(':id') @Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
return this.sshKeys.findOne(id); return this.sshKeys.findOne(id, req.user.sub);
} }
@Post() @Post()
create(@Body() dto: CreateSshKeyDto) { create(@Request() req: any, @Body() dto: CreateSshKeyDto) {
return this.sshKeys.create(dto); return this.sshKeys.create(req.user.sub, dto);
} }
@Put(':id') @Put(':id')
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateSshKeyDto) { update(@Request() req: any, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateSshKeyDto) {
return this.sshKeys.update(id, dto); return this.sshKeys.update(id, req.user.sub, dto);
} }
@Delete(':id') @Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) { remove(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
return this.sshKeys.remove(id); return this.sshKeys.remove(id, req.user.sub);
} }
} }

View File

@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { EncryptionService } from './encryption.service'; import { EncryptionService } from './encryption.service';
import { CreateSshKeyDto } from './dto/create-ssh-key.dto'; import { CreateSshKeyDto } from './dto/create-ssh-key.dto';
@ -12,19 +12,23 @@ export class SshKeysService {
private encryption: EncryptionService, private encryption: EncryptionService,
) {} ) {}
findAll() { findAll(userId: number) {
return this.prisma.sshKey.findMany({ return this.prisma.sshKey.findMany({
where: { userId },
select: { id: true, name: true, keyType: true, fingerprint: true, publicKey: true, createdAt: true }, select: { id: true, name: true, keyType: true, fingerprint: true, publicKey: true, createdAt: true },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
} }
async findOne(id: number) { async findOne(id: number, userId?: number) {
const key = await this.prisma.sshKey.findUnique({ const key = await this.prisma.sshKey.findUnique({
where: { id }, where: { id },
include: { credentials: { select: { id: true, name: true } } }, include: { credentials: { select: { id: true, name: true } } },
}); });
if (!key) throw new NotFoundException(`SSH key ${id} not found`); 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 // Never return encrypted private key over API
return { return {
id: key.id, 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 // Detect key type from private key content
const keyType = this.detectKeyType(dto.privateKey); const keyType = this.detectKeyType(dto.privateKey);
@ -56,15 +60,17 @@ export class SshKeysService {
keyType, keyType,
fingerprint, fingerprint,
publicKey: dto.publicKey || null, publicKey: dto.publicKey || null,
userId,
encryptedPrivateKey, encryptedPrivateKey,
passphraseEncrypted, passphraseEncrypted,
}, },
}); });
} }
async update(id: number, dto: UpdateSshKeyDto) { async update(id: number, userId: number, dto: UpdateSshKeyDto) {
const key = await this.prisma.sshKey.findUnique({ where: { id } }); const key = await this.prisma.sshKey.findUnique({ where: { id } });
if (!key) throw new NotFoundException(`SSH key ${id} not found`); if (!key) throw new NotFoundException(`SSH key ${id} not found`);
if (key.userId !== userId) throw new ForbiddenException('Access denied');
const data: any = {}; const data: any = {};
if (dto.name) data.name = dto.name; if (dto.name) data.name = dto.name;
@ -76,9 +82,10 @@ export class SshKeysService {
return this.prisma.sshKey.update({ where: { id }, data }); 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 } }); const key = await this.prisma.sshKey.findUnique({ where: { id } });
if (!key) throw new NotFoundException(`SSH key ${id} not found`); 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 } }); return this.prisma.sshKey.delete({ where: { id } });
} }

View File

@ -4,6 +4,7 @@ interface User {
id: number id: number
email: string email: string
displayName: string | null displayName: string | null
role: string
totpEnabled?: boolean totpEnabled?: boolean
} }
@ -20,6 +21,7 @@ export const useAuthStore = defineStore('auth', {
}), }),
getters: { getters: {
isAuthenticated: (state) => !!state.token, isAuthenticated: (state) => !!state.token,
isAdmin: (state) => state.user?.role === 'admin',
}, },
actions: { actions: {
async login(email: string, password: string, totpCode?: string): Promise<LoginResponse> { async login(email: string, password: string, totpCode?: string): Promise<LoginResponse> {