Add vault secret manager backend with AES-256-GCM encryption
Secrets are encrypted at rest in the database. The derived encryption key is held in memory only — on reboot, an authorized user must enter the master password to unlock. Admins can also manually lock the vault. - vault_config, vault_category, vault_category_permission, vault_entry tables - AES-256-GCM encryption with PBKDF2-derived key + per-entry IV - Master password initialize/unlock/lock/change lifecycle - Category CRUD with role/user permission model (view/edit/admin) - Entry CRUD with reveal endpoint (POST to avoid caching) - Secret values never returned in list/detail responses - vault.view/edit/admin RBAC permissions seeded - 19 API integration tests covering full lifecycle
This commit is contained in:
270
packages/backend/src/routes/v1/vault.ts
Normal file
270
packages/backend/src/routes/v1/vault.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { PaginationSchema } from '@forte/shared/schemas'
|
||||
import { VaultKeyService, VaultPermissionService, VaultCategoryService, VaultEntryService } from '../../services/vault.service.js'
|
||||
import { ValidationError } from '../../lib/errors.js'
|
||||
|
||||
export const vaultRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
// --- Vault Status & Key Management ---
|
||||
|
||||
app.get('/vault/status', { preHandler: [app.authenticate, app.requirePermission('vault.view')] }, async (request, reply) => {
|
||||
const initialized = await VaultKeyService.isInitialized(app.db)
|
||||
const unlocked = VaultKeyService.isUnlocked()
|
||||
return reply.send({ initialized, unlocked })
|
||||
})
|
||||
|
||||
app.post('/vault/initialize', { preHandler: [app.authenticate, app.requirePermission('vault.admin')] }, async (request, reply) => {
|
||||
const { masterPassword } = request.body as { masterPassword?: string }
|
||||
if (!masterPassword || masterPassword.length < 8) throw new ValidationError('Master password must be at least 8 characters')
|
||||
|
||||
try {
|
||||
await VaultKeyService.initialize(app.db, masterPassword)
|
||||
return reply.status(201).send({ message: 'Vault initialized and unlocked' })
|
||||
} catch (err: any) {
|
||||
return reply.status(400).send({ error: { message: err.message, statusCode: 400 } })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/vault/unlock', { preHandler: [app.authenticate, app.requirePermission('vault.view')] }, async (request, reply) => {
|
||||
const { masterPassword } = request.body as { masterPassword?: string }
|
||||
if (!masterPassword) throw new ValidationError('Master password is required')
|
||||
|
||||
const success = await VaultKeyService.unlock(app.db, masterPassword)
|
||||
if (!success) {
|
||||
return reply.status(401).send({ error: { message: 'Invalid master password', statusCode: 401 } })
|
||||
}
|
||||
return reply.send({ message: 'Vault unlocked' })
|
||||
})
|
||||
|
||||
app.post('/vault/lock', { preHandler: [app.authenticate, app.requirePermission('vault.admin')] }, async (request, reply) => {
|
||||
VaultKeyService.lock()
|
||||
return reply.send({ message: 'Vault locked' })
|
||||
})
|
||||
|
||||
app.post('/vault/change-master-password', { preHandler: [app.authenticate, app.requirePermission('vault.admin')] }, async (request, reply) => {
|
||||
const { currentPassword, newPassword } = request.body as { currentPassword?: string; newPassword?: string }
|
||||
if (!currentPassword || !newPassword) throw new ValidationError('Both current and new passwords are required')
|
||||
if (newPassword.length < 8) throw new ValidationError('New password must be at least 8 characters')
|
||||
|
||||
try {
|
||||
await VaultKeyService.changeMasterPassword(app.db, currentPassword, newPassword)
|
||||
return reply.send({ message: 'Master password changed' })
|
||||
} catch (err: any) {
|
||||
return reply.status(400).send({ error: { message: err.message, statusCode: 400 } })
|
||||
}
|
||||
})
|
||||
|
||||
// --- Middleware: require vault unlocked ---
|
||||
function requireUnlocked(_request: any, reply: any, done: () => void) {
|
||||
if (!VaultKeyService.isUnlocked()) {
|
||||
reply.status(403).send({ error: { message: 'Vault is locked', statusCode: 403 } })
|
||||
return
|
||||
}
|
||||
done()
|
||||
}
|
||||
|
||||
// --- Categories ---
|
||||
|
||||
app.post('/vault/categories', { preHandler: [app.authenticate, app.requirePermission('vault.edit'), requireUnlocked] }, async (request, reply) => {
|
||||
const { name, description, isPublic } = request.body as { name?: string; description?: string; isPublic?: boolean }
|
||||
if (!name?.trim()) throw new ValidationError('Category name is required')
|
||||
|
||||
const category = await VaultCategoryService.create(app.db, { name: name.trim(), description, isPublic, createdBy: request.user.id })
|
||||
return reply.status(201).send(category)
|
||||
})
|
||||
|
||||
app.get('/vault/categories', { preHandler: [app.authenticate, app.requirePermission('vault.view'), requireUnlocked] }, async (request, reply) => {
|
||||
const categories = await VaultCategoryService.listAccessible(app.db, request.user.id)
|
||||
return reply.send({ data: categories })
|
||||
})
|
||||
|
||||
app.get('/vault/categories/:id', { preHandler: [app.authenticate, app.requirePermission('vault.view'), requireUnlocked] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const category = await VaultCategoryService.getById(app.db, id)
|
||||
if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } })
|
||||
|
||||
const accessLevel = await VaultPermissionService.getAccessLevel(app.db, id, request.user.id)
|
||||
if (!accessLevel) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
|
||||
|
||||
return reply.send({ ...category, accessLevel })
|
||||
})
|
||||
|
||||
app.patch('/vault/categories/:id', { preHandler: [app.authenticate, app.requirePermission('vault.edit'), requireUnlocked] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const { name, description, isPublic } = request.body as { name?: string; description?: string; isPublic?: boolean }
|
||||
|
||||
const hasEdit = await VaultPermissionService.hasAccess(app.db, id, request.user.id, 'edit')
|
||||
if (!hasEdit) return reply.status(403).send({ error: { message: 'No edit access', statusCode: 403 } })
|
||||
|
||||
const category = await VaultCategoryService.update(app.db, id, { name, description, isPublic })
|
||||
if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } })
|
||||
return reply.send(category)
|
||||
})
|
||||
|
||||
app.delete('/vault/categories/:id', { preHandler: [app.authenticate, app.requirePermission('vault.admin'), requireUnlocked] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const hasAdmin = await VaultPermissionService.hasAccess(app.db, id, request.user.id, 'admin')
|
||||
if (!hasAdmin) return reply.status(403).send({ error: { message: 'Admin access required', statusCode: 403 } })
|
||||
|
||||
const category = await VaultCategoryService.delete(app.db, id)
|
||||
if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } })
|
||||
return reply.send(category)
|
||||
})
|
||||
|
||||
// --- Category Permissions ---
|
||||
|
||||
app.get('/vault/categories/:id/permissions', { preHandler: [app.authenticate, app.requirePermission('vault.view'), requireUnlocked] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const hasAdmin = await VaultPermissionService.hasAccess(app.db, id, request.user.id, 'admin')
|
||||
if (!hasAdmin) return reply.status(403).send({ error: { message: 'Admin access required', statusCode: 403 } })
|
||||
const permissions = await VaultPermissionService.listPermissions(app.db, id)
|
||||
return reply.send({ data: permissions })
|
||||
})
|
||||
|
||||
app.post('/vault/categories/:id/permissions', { preHandler: [app.authenticate, app.requirePermission('vault.edit'), requireUnlocked] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const { roleId, userId, accessLevel } = request.body as { roleId?: string; userId?: string; accessLevel?: string }
|
||||
|
||||
const hasAdmin = await VaultPermissionService.hasAccess(app.db, id, request.user.id, 'admin')
|
||||
if (!hasAdmin) return reply.status(403).send({ error: { message: 'Admin access required', statusCode: 403 } })
|
||||
|
||||
if (!roleId && !userId) throw new ValidationError('Either roleId or userId is required')
|
||||
if (!accessLevel || !['view', 'edit', 'admin'].includes(accessLevel)) throw new ValidationError('accessLevel must be view, edit, or admin')
|
||||
|
||||
const perm = await VaultPermissionService.setPermission(app.db, id, roleId, userId, accessLevel)
|
||||
return reply.status(201).send(perm)
|
||||
})
|
||||
|
||||
app.delete('/vault/category-permissions/:id', { preHandler: [app.authenticate, app.requirePermission('vault.admin'), requireUnlocked] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const perm = await VaultPermissionService.getPermissionById(app.db, id)
|
||||
if (!perm) return reply.status(404).send({ error: { message: 'Permission not found', statusCode: 404 } })
|
||||
|
||||
const hasAdmin = await VaultPermissionService.hasAccess(app.db, perm.categoryId, request.user.id, 'admin')
|
||||
if (!hasAdmin) return reply.status(403).send({ error: { message: 'Admin access required', statusCode: 403 } })
|
||||
|
||||
await VaultPermissionService.removePermission(app.db, id)
|
||||
return reply.send(perm)
|
||||
})
|
||||
|
||||
// --- Entries ---
|
||||
|
||||
app.post('/vault/categories/:categoryId/entries', { preHandler: [app.authenticate, app.requirePermission('vault.edit'), requireUnlocked] }, async (request, reply) => {
|
||||
const { categoryId } = request.params as { categoryId: string }
|
||||
const { name, username, url, notes, secret } = request.body as {
|
||||
name?: string; username?: string; url?: string; notes?: string; secret?: string
|
||||
}
|
||||
|
||||
if (!name?.trim()) throw new ValidationError('Entry name is required')
|
||||
|
||||
const hasEdit = await VaultPermissionService.hasAccess(app.db, categoryId, request.user.id, 'edit')
|
||||
if (!hasEdit) return reply.status(403).send({ error: { message: 'No edit access', statusCode: 403 } })
|
||||
|
||||
const entry = await VaultEntryService.create(app.db, {
|
||||
categoryId, name: name.trim(), username, url, notes, createdBy: request.user.id,
|
||||
}, secret)
|
||||
|
||||
// Don't return encrypted value
|
||||
return reply.status(201).send({
|
||||
id: entry.id, categoryId: entry.categoryId, name: entry.name,
|
||||
username: entry.username, url: entry.url, notes: entry.notes,
|
||||
hasSecret: !!entry.encryptedValue,
|
||||
createdBy: entry.createdBy, updatedBy: entry.updatedBy,
|
||||
createdAt: entry.createdAt, updatedAt: entry.updatedAt,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/vault/categories/:categoryId/entries', { preHandler: [app.authenticate, app.requirePermission('vault.view'), requireUnlocked] }, async (request, reply) => {
|
||||
const { categoryId } = request.params as { categoryId: string }
|
||||
|
||||
const hasView = await VaultPermissionService.hasAccess(app.db, categoryId, request.user.id, 'view')
|
||||
if (!hasView) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
|
||||
|
||||
const params = PaginationSchema.parse(request.query)
|
||||
const result = await VaultEntryService.listByCategory(app.db, categoryId, params)
|
||||
return reply.send(result)
|
||||
})
|
||||
|
||||
app.get('/vault/entries/:id', { preHandler: [app.authenticate, app.requirePermission('vault.view'), requireUnlocked] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const entry = await VaultEntryService.getById(app.db, id)
|
||||
if (!entry) return reply.status(404).send({ error: { message: 'Entry not found', statusCode: 404 } })
|
||||
|
||||
const hasView = await VaultPermissionService.hasAccess(app.db, entry.categoryId, request.user.id, 'view')
|
||||
if (!hasView) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
|
||||
|
||||
return reply.send({
|
||||
id: entry.id, categoryId: entry.categoryId, name: entry.name,
|
||||
username: entry.username, url: entry.url, notes: entry.notes,
|
||||
hasSecret: !!entry.encryptedValue,
|
||||
createdBy: entry.createdBy, updatedBy: entry.updatedBy,
|
||||
createdAt: entry.createdAt, updatedAt: entry.updatedAt,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/vault/entries/:id/reveal', { preHandler: [app.authenticate, app.requirePermission('vault.edit'), requireUnlocked] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const entry = await VaultEntryService.getById(app.db, id)
|
||||
if (!entry) return reply.status(404).send({ error: { message: 'Entry not found', statusCode: 404 } })
|
||||
|
||||
const hasEdit = await VaultPermissionService.hasAccess(app.db, entry.categoryId, request.user.id, 'edit')
|
||||
if (!hasEdit) return reply.status(403).send({ error: { message: 'Edit access required to reveal secrets', statusCode: 403 } })
|
||||
|
||||
const value = await VaultEntryService.reveal(id, app.db)
|
||||
return reply.send({ value })
|
||||
})
|
||||
|
||||
app.patch('/vault/entries/:id', { preHandler: [app.authenticate, app.requirePermission('vault.edit'), requireUnlocked] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const { name, username, url, notes, secret } = request.body as {
|
||||
name?: string; username?: string; url?: string; notes?: string; secret?: string
|
||||
}
|
||||
|
||||
const entry = await VaultEntryService.getById(app.db, id)
|
||||
if (!entry) return reply.status(404).send({ error: { message: 'Entry not found', statusCode: 404 } })
|
||||
|
||||
const hasEdit = await VaultPermissionService.hasAccess(app.db, entry.categoryId, request.user.id, 'edit')
|
||||
if (!hasEdit) return reply.status(403).send({ error: { message: 'No edit access', statusCode: 403 } })
|
||||
|
||||
const updated = await VaultEntryService.update(app.db, id, {
|
||||
name, username, url, notes, updatedBy: request.user.id,
|
||||
}, secret)
|
||||
|
||||
return reply.send({
|
||||
id: updated!.id, categoryId: updated!.categoryId, name: updated!.name,
|
||||
username: updated!.username, url: updated!.url, notes: updated!.notes,
|
||||
hasSecret: !!updated!.encryptedValue,
|
||||
createdBy: updated!.createdBy, updatedBy: updated!.updatedBy,
|
||||
createdAt: updated!.createdAt, updatedAt: updated!.updatedAt,
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/vault/entries/:id', { preHandler: [app.authenticate, app.requirePermission('vault.edit'), requireUnlocked] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const entry = await VaultEntryService.getById(app.db, id)
|
||||
if (!entry) return reply.status(404).send({ error: { message: 'Entry not found', statusCode: 404 } })
|
||||
|
||||
const hasEdit = await VaultPermissionService.hasAccess(app.db, entry.categoryId, request.user.id, 'edit')
|
||||
if (!hasEdit) return reply.status(403).send({ error: { message: 'No edit access', statusCode: 403 } })
|
||||
|
||||
await VaultEntryService.delete(app.db, id)
|
||||
return reply.send(entry)
|
||||
})
|
||||
|
||||
app.get('/vault/entries/search', { preHandler: [app.authenticate, app.requirePermission('vault.view'), requireUnlocked] }, async (request, reply) => {
|
||||
const { q } = request.query as { q?: string }
|
||||
if (!q) return reply.send({ data: [], pagination: { page: 1, limit: 25, total: 0, totalPages: 0 } })
|
||||
|
||||
const params = PaginationSchema.parse(request.query)
|
||||
const result = await VaultEntryService.search(app.db, q, params)
|
||||
|
||||
// Filter by access
|
||||
const filtered = []
|
||||
for (const entry of result.data) {
|
||||
const hasView = await VaultPermissionService.hasAccess(app.db, (entry as any).categoryId, request.user.id, 'view')
|
||||
if (hasView) filtered.push(entry)
|
||||
}
|
||||
|
||||
return reply.send({ data: filtered, pagination: { ...result.pagination, total: filtered.length } })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user