- 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
533 lines
17 KiB
TypeScript
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;
|
|
}
|