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:
parent
b749242583
commit
6d76558bc3
@ -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;
|
||||
@ -12,10 +12,16 @@ model User {
|
||||
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")
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
12
backend/src/auth/admin.guard.ts
Normal file
12
backend/src/auth/admin.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: [
|
||||
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 } }),
|
||||
);
|
||||
|
||||
@ -12,6 +12,7 @@ export class RdpGateway {
|
||||
|
||||
// Maps browser WebSocket client → live guacd TCP socket
|
||||
private clientSockets = new Map<any, net.Socket>();
|
||||
private clientUsers = new Map<any, { sub: number; email: string }>();
|
||||
|
||||
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 });
|
||||
|
||||
@ -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<boolean>,
|
||||
@ -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);
|
||||
|
||||
@ -6,6 +6,7 @@ import { SshConnectionService } from './ssh-connection.service';
|
||||
export class TerminalGateway {
|
||||
private readonly logger = new Logger(TerminalGateway.name);
|
||||
private clientSessions = new Map<any, string[]>(); // ws client → sessionIds
|
||||
private clientUsers = new Map<any, { sub: number; email: string }>(); // 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) => {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 } });
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 } });
|
||||
}
|
||||
|
||||
|
||||
@ -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<LoginResponse> {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user