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,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);

View File

@@ -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
}
]
}

View 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

View File

@@ -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',
],
},
{