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:
Ryan Moon
2026-03-30 06:11:33 -05:00
parent 748ea59c80
commit 7246587955
8 changed files with 993 additions and 0 deletions

View 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 } })
})
}