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:
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',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user