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;
|
||||||
@ -8,14 +8,20 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
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")
|
||||||
totpSecret String? @map("totp_secret")
|
role String @default("user")
|
||||||
totpEnabled Boolean @default(false) @map("totp_enabled")
|
totpSecret String? @map("totp_secret")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
totpEnabled Boolean @default(false) @map("totp_enabled")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
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")
|
@@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")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
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 { 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
@ -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' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 } }),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user