Files
cmdb-insight/backend/src/services/database/migrations.ts
Bert Hausmans 1fa424efb9 Add authentication, user management, and database migration features
- Implement OAuth 2.0 and PAT authentication methods
- Add user management, roles, and profile functionality
- Add database migrations and admin user scripts
- Update services for authentication and user settings
- Add protected routes and permission hooks
- Update documentation for authentication and database access
2026-01-15 03:20:50 +01:00

533 lines
17 KiB
TypeScript

/**
* Database Migrations
*
* Handles database schema creation and migrations for authentication and authorization system.
*/
import { logger } from '../logger.js';
import type { DatabaseAdapter } from './interface.js';
import { createDatabaseAdapter } from './factory.js';
// @ts-ignore - bcrypt doesn't have proper ESM types
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 10;
export interface Migration {
name: string;
up: (db: DatabaseAdapter) => Promise<void>;
down?: (db: DatabaseAdapter) => Promise<void>;
}
const isPostgres = (): boolean => {
return process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql';
};
const getTimestamp = (): string => {
return new Date().toISOString();
};
/**
* Create users table
*/
async function createUsersTable(db: DatabaseAdapter): Promise<void> {
const isPg = isPostgres();
const schema = isPg ? `
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT,
is_active BOOLEAN DEFAULT true,
email_verified BOOLEAN DEFAULT false,
email_verification_token TEXT,
password_reset_token TEXT,
password_reset_expires TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login TEXT
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email_verification_token ON users(email_verification_token);
CREATE INDEX IF NOT EXISTS idx_users_password_reset_token ON users(password_reset_token);
` : `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT,
is_active INTEGER DEFAULT 1,
email_verified INTEGER DEFAULT 0,
email_verification_token TEXT,
password_reset_token TEXT,
password_reset_expires TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login TEXT
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email_verification_token ON users(email_verification_token);
CREATE INDEX IF NOT EXISTS idx_users_password_reset_token ON users(password_reset_token);
`;
await db.exec(schema);
}
/**
* Create roles table
*/
async function createRolesTable(db: DatabaseAdapter): Promise<void> {
const isPg = isPostgres();
const schema = isPg ? `
CREATE TABLE IF NOT EXISTS roles (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT,
is_system_role BOOLEAN DEFAULT false,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_roles_name ON roles(name);
` : `
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
description TEXT,
is_system_role INTEGER DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_roles_name ON roles(name);
`;
await db.exec(schema);
}
/**
* Create permissions table
*/
async function createPermissionsTable(db: DatabaseAdapter): Promise<void> {
const isPg = isPostgres();
const schema = isPg ? `
CREATE TABLE IF NOT EXISTS permissions (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT,
resource TEXT
);
CREATE INDEX IF NOT EXISTS idx_permissions_name ON permissions(name);
CREATE INDEX IF NOT EXISTS idx_permissions_resource ON permissions(resource);
` : `
CREATE TABLE IF NOT EXISTS permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
description TEXT,
resource TEXT
);
CREATE INDEX IF NOT EXISTS idx_permissions_name ON permissions(name);
CREATE INDEX IF NOT EXISTS idx_permissions_resource ON permissions(resource);
`;
await db.exec(schema);
}
/**
* Create role_permissions junction table
*/
async function createRolePermissionsTable(db: DatabaseAdapter): Promise<void> {
const isPg = isPostgres();
const schema = isPg ? `
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id INTEGER NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id);
CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON role_permissions(permission_id);
` : `
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id INTEGER NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id);
CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON role_permissions(permission_id);
`;
await db.exec(schema);
}
/**
* Create user_roles junction table
*/
async function createUserRolesTable(db: DatabaseAdapter): Promise<void> {
const isPg = isPostgres();
const schema = isPg ? `
CREATE TABLE IF NOT EXISTS user_roles (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
assigned_at TEXT NOT NULL,
PRIMARY KEY (user_id, role_id)
);
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles(role_id);
` : `
CREATE TABLE IF NOT EXISTS user_roles (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
assigned_at TEXT NOT NULL,
PRIMARY KEY (user_id, role_id)
);
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles(role_id);
`;
await db.exec(schema);
}
/**
* Create user_settings table
*/
async function createUserSettingsTable(db: DatabaseAdapter): Promise<void> {
const isPg = isPostgres();
const schema = isPg ? `
CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
jira_pat TEXT,
jira_pat_encrypted BOOLEAN DEFAULT true,
ai_enabled BOOLEAN DEFAULT false,
ai_provider TEXT,
ai_api_key TEXT,
web_search_enabled BOOLEAN DEFAULT false,
tavily_api_key TEXT,
updated_at TEXT NOT NULL
);
` : `
CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
jira_pat TEXT,
jira_pat_encrypted INTEGER DEFAULT 1,
ai_enabled INTEGER DEFAULT 0,
ai_provider TEXT,
ai_api_key TEXT,
web_search_enabled INTEGER DEFAULT 0,
tavily_api_key TEXT,
updated_at TEXT NOT NULL
);
`;
await db.exec(schema);
}
/**
* Create sessions table
*/
async function createSessionsTable(db: DatabaseAdapter): Promise<void> {
const isPg = isPostgres();
const schema = isPg ? `
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
auth_method TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_sessions_auth_method ON sessions(auth_method);
` : `
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
auth_method TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_sessions_auth_method ON sessions(auth_method);
`;
await db.exec(schema);
}
/**
* Create email_tokens table
*/
async function createEmailTokensTable(db: DatabaseAdapter): Promise<void> {
const isPg = isPostgres();
const schema = isPg ? `
CREATE TABLE IF NOT EXISTS email_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
token TEXT UNIQUE NOT NULL,
type TEXT NOT NULL,
expires_at TEXT NOT NULL,
used BOOLEAN DEFAULT false,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token);
CREATE INDEX IF NOT EXISTS idx_email_tokens_user_id ON email_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_email_tokens_type ON email_tokens(type);
CREATE INDEX IF NOT EXISTS idx_email_tokens_expires_at ON email_tokens(expires_at);
` : `
CREATE TABLE IF NOT EXISTS email_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
token TEXT UNIQUE NOT NULL,
type TEXT NOT NULL,
expires_at TEXT NOT NULL,
used INTEGER DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token);
CREATE INDEX IF NOT EXISTS idx_email_tokens_user_id ON email_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_email_tokens_type ON email_tokens(type);
CREATE INDEX IF NOT EXISTS idx_email_tokens_expires_at ON email_tokens(expires_at);
`;
await db.exec(schema);
}
/**
* Seed initial data
*/
async function seedInitialData(db: DatabaseAdapter): Promise<void> {
const isPg = isPostgres();
const now = getTimestamp();
// Check if roles already exist
const existingRoles = await db.query('SELECT COUNT(*) as count FROM roles');
const roleCount = isPg ? (existingRoles[0] as any).count : (existingRoles[0] as any).count;
// If roles exist, we still need to check if admin user exists
// (roles might exist but admin user might not)
const rolesExist = parseInt(roleCount) > 0;
if (rolesExist) {
logger.info('Roles already exist, checking if admin user needs to be created...');
}
// Get existing role IDs if roles already exist
const roleIds: Record<string, number> = {};
if (!rolesExist) {
// Insert default permissions
const permissions = [
{ name: 'search', description: 'Access search features', resource: 'search' },
{ name: 'view_reports', description: 'View reports and dashboards', resource: 'reports' },
{ name: 'edit_applications', description: 'Edit application components', resource: 'applications' },
{ name: 'manage_users', description: 'Manage users and their roles', resource: 'users' },
{ name: 'manage_roles', description: 'Manage roles and permissions', resource: 'roles' },
{ name: 'manage_settings', description: 'Manage application settings', resource: 'settings' },
];
for (const perm of permissions) {
await db.execute(
'INSERT INTO permissions (name, description, resource) VALUES (?, ?, ?)',
[perm.name, perm.description, perm.resource]
);
}
// Insert default roles
const roles = [
{ name: 'administrator', description: 'Full system access', isSystem: true },
{ name: 'user', description: 'Basic user access', isSystem: true },
];
for (const role of roles) {
const isSystem = isPg ? role.isSystem : (role.isSystem ? 1 : 0);
await db.execute(
'INSERT INTO roles (name, description, is_system_role, created_at) VALUES (?, ?, ?, ?)',
[role.name, role.description, isSystem, now]
);
// Get the inserted role ID
const insertedRole = await db.queryOne<{ id: number }>(
'SELECT id FROM roles WHERE name = ?',
[role.name]
);
if (insertedRole) {
roleIds[role.name] = insertedRole.id;
}
}
// Assign all permissions to administrator role
const allPermissions = await db.query<{ id: number }>('SELECT id FROM permissions');
for (const perm of allPermissions) {
await db.execute(
'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)',
[roleIds['administrator'], perm.id]
);
}
// Assign basic permissions to user role (search and view_reports)
const searchPerm = await db.queryOne<{ id: number }>(
'SELECT id FROM permissions WHERE name = ?',
['search']
);
const viewReportsPerm = await db.queryOne<{ id: number }>(
'SELECT id FROM permissions WHERE name = ?',
['view_reports']
);
if (searchPerm) {
await db.execute(
'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)',
[roleIds['user'], searchPerm.id]
);
}
if (viewReportsPerm) {
await db.execute(
'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)',
[roleIds['user'], viewReportsPerm.id]
);
}
} else {
// Roles exist - get their IDs
const adminRole = await db.queryOne<{ id: number }>(
'SELECT id FROM roles WHERE name = ?',
['administrator']
);
if (adminRole) {
roleIds['administrator'] = adminRole.id;
}
}
// Create initial admin user if ADMIN_EMAIL and ADMIN_PASSWORD are set
const adminEmail = process.env.ADMIN_EMAIL;
const adminPassword = process.env.ADMIN_PASSWORD;
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
if (adminEmail && adminPassword) {
// Check if admin user already exists
const existingUser = await db.queryOne<{ id: number }>(
'SELECT id FROM users WHERE email = ? OR username = ?',
[adminEmail, adminUsername]
);
if (existingUser) {
// User exists - check if they have admin role
const hasAdminRole = await db.queryOne<{ role_id: number }>(
'SELECT role_id FROM user_roles WHERE user_id = ? AND role_id = ?',
[existingUser.id, roleIds['administrator']]
);
if (!hasAdminRole && roleIds['administrator']) {
// Add admin role if missing
await db.execute(
'INSERT INTO user_roles (user_id, role_id, assigned_at) VALUES (?, ?, ?)',
[existingUser.id, roleIds['administrator'], now]
);
logger.info(`Administrator role assigned to existing user: ${adminEmail}`);
} else {
logger.info(`Administrator user already exists: ${adminEmail}`);
}
} else {
// Create new admin user
const passwordHash = await bcrypt.hash(adminPassword, SALT_ROUNDS);
const displayName = process.env.ADMIN_DISPLAY_NAME || 'Administrator';
await db.execute(
'INSERT INTO users (email, username, password_hash, display_name, is_active, email_verified, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[adminEmail, adminUsername, passwordHash, displayName, isPg ? true : 1, isPg ? true : 1, now, now]
);
const adminUser = await db.queryOne<{ id: number }>(
'SELECT id FROM users WHERE email = ?',
[adminEmail]
);
if (adminUser && roleIds['administrator']) {
await db.execute(
'INSERT INTO user_roles (user_id, role_id, assigned_at) VALUES (?, ?, ?)',
[adminUser.id, roleIds['administrator'], now]
);
logger.info(`Initial administrator user created: ${adminEmail}`);
}
}
} else {
logger.warn('ADMIN_EMAIL and ADMIN_PASSWORD not set - skipping initial admin user creation');
}
logger.info('Initial data seeded successfully');
}
/**
* Main migration function
*/
export async function runMigrations(): Promise<void> {
const db = createDatabaseAdapter();
try {
logger.info('Running database migrations...');
await createUsersTable(db);
await createRolesTable(db);
await createPermissionsTable(db);
await createRolePermissionsTable(db);
await createUserRolesTable(db);
await createUserSettingsTable(db);
await createSessionsTable(db);
await createEmailTokensTable(db);
await seedInitialData(db);
logger.info('Database migrations completed successfully');
} catch (error) {
logger.error('Migration failed:', error);
throw error;
} finally {
await db.close();
}
}
// Singleton cache for auth database adapter
let authDatabaseAdapter: DatabaseAdapter | null = null;
/**
* Get database adapter for auth operations
* Uses a singleton pattern to avoid creating multiple adapters.
* The adapter is configured to not close on close() calls, as it should
* remain open for the application lifetime.
*/
export function getAuthDatabase(): DatabaseAdapter {
if (!authDatabaseAdapter) {
// Create adapter with allowClose=false so it won't be closed after operations
authDatabaseAdapter = createDatabaseAdapter(undefined, undefined, false);
}
return authDatabaseAdapter;
}