wraith/backend/src/vault/ssh-keys.service.ts
Vantz Stockwell 6d76558bc3 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>
2026-03-14 12:57:38 -04:00

108 lines
3.5 KiB
TypeScript

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';
import { UpdateSshKeyDto } from './dto/update-ssh-key.dto';
import { createHash } from 'crypto';
@Injectable()
export class SshKeysService {
constructor(
private prisma: PrismaService,
private encryption: EncryptionService,
) {}
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, 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,
name: key.name,
keyType: key.keyType,
fingerprint: key.fingerprint,
publicKey: key.publicKey,
credentials: key.credentials,
createdAt: key.createdAt,
};
}
async create(userId: number, dto: CreateSshKeyDto) {
// Detect key type from private key content
const keyType = this.detectKeyType(dto.privateKey);
// Generate fingerprint from public key if provided, else from private key
const fingerprint = this.generateFingerprint(dto.publicKey || dto.privateKey);
// Encrypt sensitive data
const encryptedPrivateKey = this.encryption.encrypt(dto.privateKey);
const passphraseEncrypted = dto.passphrase
? this.encryption.encrypt(dto.passphrase)
: null;
return this.prisma.sshKey.create({
data: {
name: dto.name,
keyType,
fingerprint,
publicKey: dto.publicKey || null,
userId,
encryptedPrivateKey,
passphraseEncrypted,
},
});
}
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;
if (dto.passphrase !== undefined) {
data.passphraseEncrypted = dto.passphrase
? this.encryption.encrypt(dto.passphrase)
: null;
}
return this.prisma.sshKey.update({ where: { id }, data });
}
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 } });
}
private detectKeyType(privateKey: string): string {
if (privateKey.includes('RSA')) return 'rsa';
if (privateKey.includes('EC')) return 'ecdsa';
if (privateKey.includes('OPENSSH')) return 'ed25519'; // OpenSSH format, likely ed25519
return 'unknown';
}
private generateFingerprint(keyContent: string): string {
try {
const hash = createHash('sha256').update(keyContent.trim()).digest('base64');
return `SHA256:${hash}`;
} catch {
return 'unknown';
}
}
}