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:
232
packages/backend/api-tests/suites/vault.ts
Normal file
232
packages/backend/api-tests/suites/vault.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { suite } from '../lib/context.js'
|
||||
|
||||
const MASTER_PASSWORD = 'test-vault-master-2024!'
|
||||
|
||||
suite('Vault', { tags: ['vault'] }, (t) => {
|
||||
|
||||
// --- Initialization & Unlock ---
|
||||
|
||||
t.test('initializes vault with master password', { tags: ['init'] }, async () => {
|
||||
const res = await t.api.post('/v1/vault/initialize', { masterPassword: MASTER_PASSWORD })
|
||||
t.assert.status(res, 201)
|
||||
t.assert.contains(res.data.message, 'initialized')
|
||||
})
|
||||
|
||||
t.test('rejects re-initialization', { tags: ['init'] }, async () => {
|
||||
const res = await t.api.post('/v1/vault/initialize', { masterPassword: 'another-password' })
|
||||
t.assert.status(res, 400)
|
||||
})
|
||||
|
||||
t.test('reports vault status', { tags: ['status'] }, async () => {
|
||||
const res = await t.api.get('/v1/vault/status')
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.initialized, true)
|
||||
t.assert.equal(res.data.unlocked, true)
|
||||
})
|
||||
|
||||
t.test('locks vault', { tags: ['lock'] }, async () => {
|
||||
const res = await t.api.post('/v1/vault/lock')
|
||||
t.assert.status(res, 200)
|
||||
|
||||
const status = await t.api.get('/v1/vault/status')
|
||||
t.assert.equal(status.data.unlocked, false)
|
||||
})
|
||||
|
||||
t.test('rejects operations when locked', { tags: ['lock'] }, async () => {
|
||||
const res = await t.api.get('/v1/vault/categories')
|
||||
t.assert.status(res, 403)
|
||||
t.assert.contains(res.data.error.message, 'locked')
|
||||
})
|
||||
|
||||
t.test('unlocks vault with correct password', { tags: ['lock'] }, async () => {
|
||||
const res = await t.api.post('/v1/vault/unlock', { masterPassword: MASTER_PASSWORD })
|
||||
t.assert.status(res, 200)
|
||||
|
||||
const status = await t.api.get('/v1/vault/status')
|
||||
t.assert.equal(status.data.unlocked, true)
|
||||
})
|
||||
|
||||
t.test('rejects unlock with wrong password', { tags: ['lock'] }, async () => {
|
||||
await t.api.post('/v1/vault/lock')
|
||||
const res = await t.api.post('/v1/vault/unlock', { masterPassword: 'wrong-password' })
|
||||
t.assert.status(res, 401)
|
||||
|
||||
// Re-unlock for remaining tests
|
||||
await t.api.post('/v1/vault/unlock', { masterPassword: MASTER_PASSWORD })
|
||||
})
|
||||
|
||||
// --- Categories ---
|
||||
|
||||
t.test('creates a category', { tags: ['category'] }, async () => {
|
||||
const res = await t.api.post('/v1/vault/categories', { name: 'WiFi Passwords', description: 'Store WiFi credentials' })
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.name, 'WiFi Passwords')
|
||||
t.assert.ok(res.data.id)
|
||||
})
|
||||
|
||||
t.test('lists accessible categories', { tags: ['category'] }, async () => {
|
||||
const res = await t.api.get('/v1/vault/categories')
|
||||
t.assert.status(res, 200)
|
||||
t.assert.greaterThan(res.data.data.length, 0)
|
||||
})
|
||||
|
||||
t.test('gets category detail with accessLevel', { tags: ['category'] }, async () => {
|
||||
const list = await t.api.get('/v1/vault/categories')
|
||||
const catId = list.data.data[0].id
|
||||
|
||||
const res = await t.api.get(`/v1/vault/categories/${catId}`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.accessLevel, 'admin')
|
||||
})
|
||||
|
||||
t.test('updates a category', { tags: ['category'] }, async () => {
|
||||
const list = await t.api.get('/v1/vault/categories')
|
||||
const catId = list.data.data[0].id
|
||||
|
||||
const res = await t.api.patch(`/v1/vault/categories/${catId}`, { name: 'WiFi & Network' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.name, 'WiFi & Network')
|
||||
})
|
||||
|
||||
// --- Entries ---
|
||||
|
||||
t.test('creates an entry with a secret', { tags: ['entry'] }, async () => {
|
||||
const list = await t.api.get('/v1/vault/categories')
|
||||
const catId = list.data.data[0].id
|
||||
|
||||
const res = await t.api.post(`/v1/vault/categories/${catId}/entries`, {
|
||||
name: 'Store WiFi',
|
||||
username: 'ForteMusic',
|
||||
url: 'http://192.168.1.1',
|
||||
notes: 'Router admin panel',
|
||||
secret: 'supersecretpassword123',
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.name, 'Store WiFi')
|
||||
t.assert.equal(res.data.username, 'ForteMusic')
|
||||
t.assert.equal(res.data.hasSecret, true)
|
||||
// Secret value should NOT be in the response
|
||||
t.assert.falsy(res.data.encryptedValue)
|
||||
t.assert.falsy(res.data.iv)
|
||||
t.assert.falsy(res.data.secret)
|
||||
})
|
||||
|
||||
t.test('lists entries without revealing secrets', { tags: ['entry'] }, async () => {
|
||||
const cats = await t.api.get('/v1/vault/categories')
|
||||
const catId = cats.data.data[0].id
|
||||
|
||||
const res = await t.api.get(`/v1/vault/categories/${catId}/entries`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.greaterThan(res.data.data.length, 0)
|
||||
t.assert.equal(res.data.data[0].hasSecret, true)
|
||||
t.assert.falsy(res.data.data[0].encryptedValue)
|
||||
})
|
||||
|
||||
t.test('reveals a secret', { tags: ['entry', 'reveal'] }, async () => {
|
||||
const cats = await t.api.get('/v1/vault/categories')
|
||||
const catId = cats.data.data[0].id
|
||||
const entries = await t.api.get(`/v1/vault/categories/${catId}/entries`)
|
||||
const entryId = entries.data.data[0].id
|
||||
|
||||
const res = await t.api.post(`/v1/vault/entries/${entryId}/reveal`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.value, 'supersecretpassword123')
|
||||
})
|
||||
|
||||
t.test('updates an entry with new secret', { tags: ['entry'] }, async () => {
|
||||
const cats = await t.api.get('/v1/vault/categories')
|
||||
const catId = cats.data.data[0].id
|
||||
const entries = await t.api.get(`/v1/vault/categories/${catId}/entries`)
|
||||
const entryId = entries.data.data[0].id
|
||||
|
||||
const res = await t.api.patch(`/v1/vault/entries/${entryId}`, {
|
||||
name: 'Store WiFi (updated)',
|
||||
secret: 'newsecret456',
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.name, 'Store WiFi (updated)')
|
||||
|
||||
// Verify new secret
|
||||
const reveal = await t.api.post(`/v1/vault/entries/${entryId}/reveal`)
|
||||
t.assert.equal(reveal.data.value, 'newsecret456')
|
||||
})
|
||||
|
||||
t.test('creates entry without secret', { tags: ['entry'] }, async () => {
|
||||
const cats = await t.api.get('/v1/vault/categories')
|
||||
const catId = cats.data.data[0].id
|
||||
|
||||
const res = await t.api.post(`/v1/vault/categories/${catId}/entries`, {
|
||||
name: 'Vendor Contact',
|
||||
notes: 'Call 555-1234',
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.hasSecret, false)
|
||||
})
|
||||
|
||||
t.test('deletes an entry', { tags: ['entry'] }, async () => {
|
||||
const cats = await t.api.get('/v1/vault/categories')
|
||||
const catId = cats.data.data[0].id
|
||||
|
||||
// Create one to delete
|
||||
const created = await t.api.post(`/v1/vault/categories/${catId}/entries`, {
|
||||
name: 'To Delete',
|
||||
secret: 'deleteme',
|
||||
})
|
||||
|
||||
const res = await t.api.del(`/v1/vault/entries/${created.data.id}`)
|
||||
t.assert.status(res, 200)
|
||||
|
||||
const check = await t.api.get(`/v1/vault/entries/${created.data.id}`)
|
||||
t.assert.status(check, 404)
|
||||
})
|
||||
|
||||
// --- Master Password Change ---
|
||||
|
||||
t.test('changes master password and secrets still work', { tags: ['master'] }, async () => {
|
||||
const newMaster = 'new-master-password-2024!'
|
||||
|
||||
const res = await t.api.post('/v1/vault/change-master-password', {
|
||||
currentPassword: MASTER_PASSWORD,
|
||||
newPassword: newMaster,
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
|
||||
// Verify existing secrets still decrypt
|
||||
const cats = await t.api.get('/v1/vault/categories')
|
||||
const catId = cats.data.data[0].id
|
||||
const entries = await t.api.get(`/v1/vault/categories/${catId}/entries`)
|
||||
const secretEntry = entries.data.data.find((e: any) => e.hasSecret)
|
||||
if (secretEntry) {
|
||||
const reveal = await t.api.post(`/v1/vault/entries/${secretEntry.id}/reveal`)
|
||||
t.assert.status(reveal, 200)
|
||||
t.assert.ok(reveal.data.value)
|
||||
}
|
||||
|
||||
// Lock and re-unlock with new password
|
||||
await t.api.post('/v1/vault/lock')
|
||||
const unlock = await t.api.post('/v1/vault/unlock', { masterPassword: newMaster })
|
||||
t.assert.status(unlock, 200)
|
||||
|
||||
// Old password should fail
|
||||
await t.api.post('/v1/vault/lock')
|
||||
const oldUnlock = await t.api.post('/v1/vault/unlock', { masterPassword: MASTER_PASSWORD })
|
||||
t.assert.status(oldUnlock, 401)
|
||||
|
||||
// Re-unlock with new password for cleanup
|
||||
await t.api.post('/v1/vault/unlock', { masterPassword: newMaster })
|
||||
})
|
||||
|
||||
// --- Delete category ---
|
||||
|
||||
t.test('deletes a category and cascades entries', { tags: ['category'] }, async () => {
|
||||
// Create a fresh category with an entry
|
||||
const cat = await t.api.post('/v1/vault/categories', { name: 'To Delete Cat' })
|
||||
await t.api.post(`/v1/vault/categories/${cat.data.id}/entries`, { name: 'Temp Entry', secret: 'temp' })
|
||||
|
||||
const res = await t.api.del(`/v1/vault/categories/${cat.data.id}`)
|
||||
t.assert.status(res, 200)
|
||||
|
||||
const check = await t.api.get(`/v1/vault/categories/${cat.data.id}`)
|
||||
t.assert.status(check, 404)
|
||||
})
|
||||
})
|
||||
46
packages/backend/src/db/migrations/0025_vault.sql
Normal file
46
packages/backend/src/db/migrations/0025_vault.sql
Normal file
@@ -0,0 +1,46 @@
|
||||
-- Vault secret manager tables
|
||||
|
||||
CREATE TABLE vault_config (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
master_key_salt TEXT NOT NULL,
|
||||
master_key_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE vault_category (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
created_by UUID REFERENCES "user"(id),
|
||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE vault_category_permission (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
category_id UUID NOT NULL REFERENCES vault_category(id) ON DELETE CASCADE,
|
||||
role_id UUID REFERENCES role(id),
|
||||
user_id UUID REFERENCES "user"(id),
|
||||
access_level storage_folder_access NOT NULL DEFAULT 'view',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE vault_entry (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
category_id UUID NOT NULL REFERENCES vault_category(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
username VARCHAR(255),
|
||||
url VARCHAR(1000),
|
||||
notes TEXT,
|
||||
encrypted_value TEXT,
|
||||
iv TEXT,
|
||||
created_by UUID REFERENCES "user"(id),
|
||||
updated_by UUID REFERENCES "user"(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_vault_category_permission_category ON vault_category_permission(category_id);
|
||||
CREATE INDEX idx_vault_entry_category ON vault_entry(category_id);
|
||||
@@ -176,6 +176,13 @@
|
||||
"when": 1774840000000,
|
||||
"tag": "0024_add_traverse_access_level",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1774850000000,
|
||||
"tag": "0025_vault",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
57
packages/backend/src/db/schema/vault.ts
Normal file
57
packages/backend/src/db/schema/vault.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { pgTable, uuid, varchar, text, timestamp, boolean } from 'drizzle-orm/pg-core'
|
||||
import { users } from './users.js'
|
||||
import { roles } from './rbac.js'
|
||||
import { storageFolderAccessEnum } from './storage.js'
|
||||
|
||||
export const vaultConfig = pgTable('vault_config', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
masterKeySalt: text('master_key_salt').notNull(),
|
||||
masterKeyHash: text('master_key_hash').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const vaultCategories = pgTable('vault_category', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
description: text('description'),
|
||||
createdBy: uuid('created_by').references(() => users.id),
|
||||
isPublic: boolean('is_public').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const vaultCategoryPermissions = pgTable('vault_category_permission', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
categoryId: uuid('category_id')
|
||||
.notNull()
|
||||
.references(() => vaultCategories.id, { onDelete: 'cascade' }),
|
||||
roleId: uuid('role_id').references(() => roles.id),
|
||||
userId: uuid('user_id').references(() => users.id),
|
||||
accessLevel: storageFolderAccessEnum('access_level').notNull().default('view'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const vaultEntries = pgTable('vault_entry', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
categoryId: uuid('category_id')
|
||||
.notNull()
|
||||
.references(() => vaultCategories.id, { onDelete: 'cascade' }),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
username: varchar('username', { length: 255 }),
|
||||
url: varchar('url', { length: 1000 }),
|
||||
notes: text('notes'),
|
||||
encryptedValue: text('encrypted_value'),
|
||||
iv: text('iv'),
|
||||
createdBy: uuid('created_by').references(() => users.id),
|
||||
updatedBy: uuid('updated_by').references(() => users.id),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export type VaultConfig = typeof vaultConfig.$inferSelect
|
||||
export type VaultCategory = typeof vaultCategories.$inferSelect
|
||||
export type VaultCategoryInsert = typeof vaultCategories.$inferInsert
|
||||
export type VaultCategoryPermission = typeof vaultCategoryPermissions.$inferSelect
|
||||
export type VaultEntry = typeof vaultEntries.$inferSelect
|
||||
export type VaultEntryInsert = typeof vaultEntries.$inferInsert
|
||||
@@ -49,6 +49,11 @@ export const SYSTEM_PERMISSIONS = [
|
||||
{ slug: 'files.upload', domain: 'files', action: 'upload', description: 'Upload files' },
|
||||
{ slug: 'files.delete', domain: 'files', action: 'delete', description: 'Delete files' },
|
||||
|
||||
// Vault
|
||||
{ slug: 'vault.view', domain: 'vault', action: 'view', description: 'View vault categories and entry names' },
|
||||
{ slug: 'vault.edit', domain: 'vault', action: 'edit', description: 'Create/edit entries, reveal secret values' },
|
||||
{ slug: 'vault.admin', domain: 'vault', action: 'admin', description: 'Manage categories, permissions, master password' },
|
||||
|
||||
// Email
|
||||
{ slug: 'email.view', domain: 'email', action: 'view', description: 'View email logs' },
|
||||
{ slug: 'email.send', domain: 'email', action: 'send', description: 'Send mass emails' },
|
||||
@@ -99,6 +104,7 @@ export const DEFAULT_ROLES = [
|
||||
'pos.view', 'pos.edit',
|
||||
'rentals.view',
|
||||
'files.view', 'files.upload',
|
||||
'vault.view',
|
||||
'reports.view',
|
||||
],
|
||||
},
|
||||
@@ -111,6 +117,7 @@ export const DEFAULT_ROLES = [
|
||||
'inventory.view',
|
||||
'accounts.view',
|
||||
'files.view', 'files.upload',
|
||||
'vault.view',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ import { rbacRoutes } from './routes/v1/rbac.js'
|
||||
import { repairRoutes } from './routes/v1/repairs.js'
|
||||
import { storageRoutes } from './routes/v1/storage.js'
|
||||
import { storeRoutes } from './routes/v1/store.js'
|
||||
import { vaultRoutes } from './routes/v1/vault.js'
|
||||
import { webdavRoutes } from './routes/webdav/index.js'
|
||||
import { RbacService } from './services/rbac.service.js'
|
||||
|
||||
@@ -72,6 +73,7 @@ export async function buildApp() {
|
||||
await app.register(repairRoutes, { prefix: '/v1' })
|
||||
await app.register(storageRoutes, { prefix: '/v1' })
|
||||
await app.register(storeRoutes, { prefix: '/v1' })
|
||||
await app.register(vaultRoutes, { prefix: '/v1' })
|
||||
// Register WebDAV custom HTTP methods before routes
|
||||
app.addHttpMethod('PROPFIND', { hasBody: true })
|
||||
app.addHttpMethod('PROPPATCH', { hasBody: true })
|
||||
|
||||
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 } })
|
||||
})
|
||||
}
|
||||
372
packages/backend/src/services/vault.service.ts
Normal file
372
packages/backend/src/services/vault.service.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import { eq, and, count, inArray, type Column } from 'drizzle-orm'
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||
import { vaultConfig, vaultCategories, vaultCategoryPermissions, vaultEntries } from '../db/schema/vault.js'
|
||||
import { userRoles } from '../db/schema/rbac.js'
|
||||
import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js'
|
||||
import type { PaginationInput } from '@forte/shared/schemas'
|
||||
import { randomBytes, createCipheriv, createDecipheriv, pbkdf2Sync } from 'crypto'
|
||||
import bcrypt from 'bcrypt'
|
||||
|
||||
// --- Encryption key held in memory ---
|
||||
let derivedKey: Buffer | null = null
|
||||
|
||||
const PBKDF2_ITERATIONS = 100_000
|
||||
const KEY_LENGTH = 32 // 256 bits for AES-256
|
||||
const ALGORITHM = 'aes-256-gcm'
|
||||
|
||||
const ACCESS_RANK: Record<string, number> = { traverse: 0, view: 1, edit: 2, admin: 3 }
|
||||
|
||||
// --- Key Service ---
|
||||
|
||||
export const VaultKeyService = {
|
||||
isUnlocked(): boolean {
|
||||
return derivedKey !== null
|
||||
},
|
||||
|
||||
async isInitialized(db: PostgresJsDatabase<any>): Promise<boolean> {
|
||||
const [config] = await db.select({ id: vaultConfig.id }).from(vaultConfig).limit(1)
|
||||
return !!config
|
||||
},
|
||||
|
||||
async initialize(db: PostgresJsDatabase<any>, masterPassword: string): Promise<void> {
|
||||
const existing = await this.isInitialized(db)
|
||||
if (existing) throw new Error('Vault is already initialized')
|
||||
|
||||
const salt = randomBytes(32).toString('hex')
|
||||
const hash = await bcrypt.hash(masterPassword, 10)
|
||||
derivedKey = pbkdf2Sync(masterPassword, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha512')
|
||||
|
||||
await db.insert(vaultConfig).values({
|
||||
masterKeySalt: salt,
|
||||
masterKeyHash: hash,
|
||||
})
|
||||
},
|
||||
|
||||
async unlock(db: PostgresJsDatabase<any>, masterPassword: string): Promise<boolean> {
|
||||
const [config] = await db.select().from(vaultConfig).limit(1)
|
||||
if (!config) throw new Error('Vault is not initialized')
|
||||
|
||||
const valid = await bcrypt.compare(masterPassword, config.masterKeyHash)
|
||||
if (!valid) return false
|
||||
|
||||
derivedKey = pbkdf2Sync(masterPassword, config.masterKeySalt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha512')
|
||||
return true
|
||||
},
|
||||
|
||||
lock(): void {
|
||||
derivedKey = null
|
||||
},
|
||||
|
||||
async changeMasterPassword(db: PostgresJsDatabase<any>, currentPassword: string, newPassword: string): Promise<void> {
|
||||
if (!derivedKey) throw new Error('Vault is locked')
|
||||
|
||||
const [config] = await db.select().from(vaultConfig).limit(1)
|
||||
if (!config) throw new Error('Vault is not initialized')
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, config.masterKeyHash)
|
||||
if (!valid) throw new Error('Current password is incorrect')
|
||||
|
||||
// Generate new key material
|
||||
const newSalt = randomBytes(32).toString('hex')
|
||||
const newHash = await bcrypt.hash(newPassword, 10)
|
||||
const newKey = pbkdf2Sync(newPassword, newSalt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha512')
|
||||
|
||||
// Re-encrypt all entries
|
||||
const entries = await db.select().from(vaultEntries)
|
||||
for (const entry of entries) {
|
||||
if (entry.encryptedValue && entry.iv) {
|
||||
// Decrypt with old key
|
||||
const plaintext = decrypt(entry.encryptedValue, entry.iv)
|
||||
// Encrypt with new key
|
||||
const { ciphertext, iv } = encryptWith(plaintext, newKey)
|
||||
await db.update(vaultEntries).set({ encryptedValue: ciphertext, iv, updatedAt: new Date() }).where(eq(vaultEntries.id, entry.id))
|
||||
}
|
||||
}
|
||||
|
||||
// Update config
|
||||
await db.update(vaultConfig).set({
|
||||
masterKeySalt: newSalt,
|
||||
masterKeyHash: newHash,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(vaultConfig.id, config.id))
|
||||
|
||||
derivedKey = newKey
|
||||
},
|
||||
}
|
||||
|
||||
// --- Encryption helpers ---
|
||||
|
||||
function encrypt(plaintext: string): { ciphertext: string; iv: string } {
|
||||
if (!derivedKey) throw new Error('Vault is locked')
|
||||
return encryptWith(plaintext, derivedKey)
|
||||
}
|
||||
|
||||
function encryptWith(plaintext: string, key: Buffer): { ciphertext: string; iv: string } {
|
||||
const iv = randomBytes(12)
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv)
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
const authTag = cipher.getAuthTag().toString('hex')
|
||||
return {
|
||||
ciphertext: encrypted + ':' + authTag,
|
||||
iv: iv.toString('hex'),
|
||||
}
|
||||
}
|
||||
|
||||
function decrypt(ciphertext: string, iv: string): string {
|
||||
if (!derivedKey) throw new Error('Vault is locked')
|
||||
const [encrypted, authTag] = ciphertext.split(':')
|
||||
const decipher = createDecipheriv(ALGORITHM, derivedKey, Buffer.from(iv, 'hex'))
|
||||
decipher.setAuthTag(Buffer.from(authTag, 'hex'))
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
return decrypted
|
||||
}
|
||||
|
||||
// --- Permission Service ---
|
||||
|
||||
export const VaultPermissionService = {
|
||||
async hasAccess(db: PostgresJsDatabase<any>, categoryId: string, userId: string, minLevel: 'traverse' | 'view' | 'edit' | 'admin' = 'view'): Promise<boolean> {
|
||||
const level = await this.getAccessLevel(db, categoryId, userId)
|
||||
if (!level) return false
|
||||
return ACCESS_RANK[level] >= ACCESS_RANK[minLevel]
|
||||
},
|
||||
|
||||
async getAccessLevel(db: PostgresJsDatabase<any>, categoryId: string, userId: string): Promise<'admin' | 'edit' | 'view' | 'traverse' | null> {
|
||||
const [category] = await db.select({ isPublic: vaultCategories.isPublic, createdBy: vaultCategories.createdBy })
|
||||
.from(vaultCategories).where(eq(vaultCategories.id, categoryId)).limit(1)
|
||||
if (!category) return null
|
||||
|
||||
// Creator always has admin
|
||||
if (category.createdBy === userId) return 'admin'
|
||||
|
||||
// Check direct user permission
|
||||
const [userPerm] = await db.select({ accessLevel: vaultCategoryPermissions.accessLevel }).from(vaultCategoryPermissions)
|
||||
.where(and(eq(vaultCategoryPermissions.categoryId, categoryId), eq(vaultCategoryPermissions.userId, userId)))
|
||||
.limit(1)
|
||||
if (userPerm) return userPerm.accessLevel
|
||||
|
||||
// Check role-based permissions — pick highest across all roles
|
||||
const userRoleRows = await db.select({ roleId: userRoles.roleId }).from(userRoles).where(eq(userRoles.userId, userId))
|
||||
if (userRoleRows.length > 0) {
|
||||
const roleIds = userRoleRows.map((r) => r.roleId)
|
||||
const rolePerms = await db.select({ accessLevel: vaultCategoryPermissions.accessLevel }).from(vaultCategoryPermissions)
|
||||
.where(and(eq(vaultCategoryPermissions.categoryId, categoryId), inArray(vaultCategoryPermissions.roleId, roleIds)))
|
||||
let best: string | null = null
|
||||
for (const rp of rolePerms) {
|
||||
if (!best || ACCESS_RANK[rp.accessLevel] > ACCESS_RANK[best]) best = rp.accessLevel
|
||||
}
|
||||
if (best) return best as any
|
||||
}
|
||||
|
||||
// Public categories give view access
|
||||
if (category.isPublic) return 'view'
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
async listPermissions(db: PostgresJsDatabase<any>, categoryId: string) {
|
||||
return db.select().from(vaultCategoryPermissions).where(eq(vaultCategoryPermissions.categoryId, categoryId))
|
||||
},
|
||||
|
||||
async getPermissionById(db: PostgresJsDatabase<any>, permissionId: string) {
|
||||
const [perm] = await db.select().from(vaultCategoryPermissions).where(eq(vaultCategoryPermissions.id, permissionId)).limit(1)
|
||||
return perm ?? null
|
||||
},
|
||||
|
||||
async setPermission(db: PostgresJsDatabase<any>, categoryId: string, roleId: string | undefined, userId: string | undefined, accessLevel: string) {
|
||||
if (roleId) {
|
||||
await db.delete(vaultCategoryPermissions).where(and(eq(vaultCategoryPermissions.categoryId, categoryId), eq(vaultCategoryPermissions.roleId, roleId)))
|
||||
}
|
||||
if (userId) {
|
||||
await db.delete(vaultCategoryPermissions).where(and(eq(vaultCategoryPermissions.categoryId, categoryId), eq(vaultCategoryPermissions.userId, userId)))
|
||||
}
|
||||
|
||||
const [perm] = await db.insert(vaultCategoryPermissions).values({
|
||||
categoryId,
|
||||
roleId: roleId ?? null,
|
||||
userId: userId ?? null,
|
||||
accessLevel: accessLevel as any,
|
||||
}).returning()
|
||||
return perm
|
||||
},
|
||||
|
||||
async removePermission(db: PostgresJsDatabase<any>, permissionId: string) {
|
||||
const [perm] = await db.delete(vaultCategoryPermissions).where(eq(vaultCategoryPermissions.id, permissionId)).returning()
|
||||
return perm ?? null
|
||||
},
|
||||
}
|
||||
|
||||
// --- Category Service ---
|
||||
|
||||
export const VaultCategoryService = {
|
||||
async create(db: PostgresJsDatabase<any>, input: { name: string; description?: string; isPublic?: boolean; createdBy?: string }) {
|
||||
const [category] = await db.insert(vaultCategories).values({
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
isPublic: input.isPublic ?? false,
|
||||
createdBy: input.createdBy ?? null,
|
||||
}).returning()
|
||||
return category
|
||||
},
|
||||
|
||||
async getById(db: PostgresJsDatabase<any>, id: string) {
|
||||
const [category] = await db.select().from(vaultCategories).where(eq(vaultCategories.id, id)).limit(1)
|
||||
return category ?? null
|
||||
},
|
||||
|
||||
async listAccessible(db: PostgresJsDatabase<any>, userId: string) {
|
||||
const all = await db.select().from(vaultCategories).orderBy(vaultCategories.name)
|
||||
const accessible = []
|
||||
for (const cat of all) {
|
||||
const level = await VaultPermissionService.getAccessLevel(db, cat.id, userId)
|
||||
if (level) accessible.push(cat)
|
||||
}
|
||||
return accessible
|
||||
},
|
||||
|
||||
async update(db: PostgresJsDatabase<any>, id: string, input: { name?: string; description?: string; isPublic?: boolean }) {
|
||||
const [category] = await db.update(vaultCategories).set({ ...input, updatedAt: new Date() }).where(eq(vaultCategories.id, id)).returning()
|
||||
return category ?? null
|
||||
},
|
||||
|
||||
async delete(db: PostgresJsDatabase<any>, id: string) {
|
||||
const [category] = await db.delete(vaultCategories).where(eq(vaultCategories.id, id)).returning()
|
||||
return category ?? null
|
||||
},
|
||||
}
|
||||
|
||||
// --- Entry Service ---
|
||||
|
||||
export const VaultEntryService = {
|
||||
async create(db: PostgresJsDatabase<any>, input: {
|
||||
categoryId: string; name: string; username?: string; url?: string; notes?: string; createdBy?: string
|
||||
}, secret?: string) {
|
||||
let encryptedValue: string | null = null
|
||||
let iv: string | null = null
|
||||
if (secret) {
|
||||
const encrypted = encrypt(secret)
|
||||
encryptedValue = encrypted.ciphertext
|
||||
iv = encrypted.iv
|
||||
}
|
||||
|
||||
const [entry] = await db.insert(vaultEntries).values({
|
||||
categoryId: input.categoryId,
|
||||
name: input.name,
|
||||
username: input.username ?? null,
|
||||
url: input.url ?? null,
|
||||
notes: input.notes ?? null,
|
||||
encryptedValue,
|
||||
iv,
|
||||
createdBy: input.createdBy ?? null,
|
||||
updatedBy: input.createdBy ?? null,
|
||||
}).returning()
|
||||
return entry
|
||||
},
|
||||
|
||||
async getById(db: PostgresJsDatabase<any>, id: string) {
|
||||
const [entry] = await db.select().from(vaultEntries).where(eq(vaultEntries.id, id)).limit(1)
|
||||
return entry ?? null
|
||||
},
|
||||
|
||||
async listByCategory(db: PostgresJsDatabase<any>, categoryId: string, params: PaginationInput) {
|
||||
const baseWhere = eq(vaultEntries.categoryId, categoryId)
|
||||
const searchCondition = params.q ? buildSearchCondition(params.q, [vaultEntries.name, vaultEntries.username]) : undefined
|
||||
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
|
||||
|
||||
const sortableColumns: Record<string, Column> = {
|
||||
name: vaultEntries.name,
|
||||
username: vaultEntries.username,
|
||||
created_at: vaultEntries.createdAt,
|
||||
updated_at: vaultEntries.updatedAt,
|
||||
}
|
||||
|
||||
let query = db.select({
|
||||
id: vaultEntries.id,
|
||||
categoryId: vaultEntries.categoryId,
|
||||
name: vaultEntries.name,
|
||||
username: vaultEntries.username,
|
||||
url: vaultEntries.url,
|
||||
notes: vaultEntries.notes,
|
||||
createdBy: vaultEntries.createdBy,
|
||||
updatedBy: vaultEntries.updatedBy,
|
||||
createdAt: vaultEntries.createdAt,
|
||||
updatedAt: vaultEntries.updatedAt,
|
||||
// Expose whether a secret is set, but never the value
|
||||
hasSecret: vaultEntries.encryptedValue,
|
||||
}).from(vaultEntries).where(where).$dynamic()
|
||||
query = withSort(query, params.sort, params.order, sortableColumns, vaultEntries.name)
|
||||
query = withPagination(query, params.page, params.limit)
|
||||
|
||||
const [data, [{ total }]] = await Promise.all([
|
||||
query,
|
||||
db.select({ total: count() }).from(vaultEntries).where(where),
|
||||
])
|
||||
|
||||
// Transform hasSecret from encrypted value to boolean
|
||||
const sanitized = data.map((e) => ({ ...e, hasSecret: !!e.hasSecret }))
|
||||
|
||||
return paginatedResponse(sanitized, total, params.page, params.limit)
|
||||
},
|
||||
|
||||
async reveal(id: string, db: PostgresJsDatabase<any>): Promise<string | null> {
|
||||
if (!VaultKeyService.isUnlocked()) throw new Error('Vault is locked')
|
||||
const [entry] = await db.select({ encryptedValue: vaultEntries.encryptedValue, iv: vaultEntries.iv })
|
||||
.from(vaultEntries).where(eq(vaultEntries.id, id)).limit(1)
|
||||
if (!entry?.encryptedValue || !entry.iv) return null
|
||||
return decrypt(entry.encryptedValue, entry.iv)
|
||||
},
|
||||
|
||||
async update(db: PostgresJsDatabase<any>, id: string, input: {
|
||||
name?: string; username?: string; url?: string; notes?: string; updatedBy?: string
|
||||
}, newSecret?: string) {
|
||||
const updates: Record<string, any> = { ...input, updatedAt: new Date() }
|
||||
|
||||
if (newSecret !== undefined) {
|
||||
if (newSecret) {
|
||||
const encrypted = encrypt(newSecret)
|
||||
updates.encryptedValue = encrypted.ciphertext
|
||||
updates.iv = encrypted.iv
|
||||
} else {
|
||||
updates.encryptedValue = null
|
||||
updates.iv = null
|
||||
}
|
||||
}
|
||||
|
||||
const [entry] = await db.update(vaultEntries).set(updates).where(eq(vaultEntries.id, id)).returning()
|
||||
return entry ?? null
|
||||
},
|
||||
|
||||
async delete(db: PostgresJsDatabase<any>, id: string) {
|
||||
const [entry] = await db.delete(vaultEntries).where(eq(vaultEntries.id, id)).returning()
|
||||
return entry ?? null
|
||||
},
|
||||
|
||||
async search(db: PostgresJsDatabase<any>, query: string, params: PaginationInput) {
|
||||
const searchCondition = buildSearchCondition(query, [vaultEntries.name, vaultEntries.username])
|
||||
if (!searchCondition) return paginatedResponse([], 0, params.page, params.limit)
|
||||
|
||||
let q = db.select({
|
||||
id: vaultEntries.id,
|
||||
categoryId: vaultEntries.categoryId,
|
||||
name: vaultEntries.name,
|
||||
username: vaultEntries.username,
|
||||
url: vaultEntries.url,
|
||||
notes: vaultEntries.notes,
|
||||
createdBy: vaultEntries.createdBy,
|
||||
updatedBy: vaultEntries.updatedBy,
|
||||
createdAt: vaultEntries.createdAt,
|
||||
updatedAt: vaultEntries.updatedAt,
|
||||
hasSecret: vaultEntries.encryptedValue,
|
||||
}).from(vaultEntries).where(searchCondition).$dynamic()
|
||||
q = withPagination(q, params.page, params.limit)
|
||||
|
||||
const [data, [{ total }]] = await Promise.all([
|
||||
q,
|
||||
db.select({ total: count() }).from(vaultEntries).where(searchCondition),
|
||||
])
|
||||
|
||||
const sanitized = data.map((e) => ({ ...e, hasSecret: !!e.hasSecret }))
|
||||
return paginatedResponse(sanitized, total, params.page, params.limit)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user