import { Injectable, Logger } from '@nestjs/common'; import { TenantDatabaseService } from './tenant-database.service'; import * as knex from 'knex'; import * as crypto from 'crypto'; import { getCentralPrisma } from '../prisma/central-prisma.service'; @Injectable() export class TenantProvisioningService { private readonly logger = new Logger(TenantProvisioningService.name); constructor(private readonly tenantDbService: TenantDatabaseService) {} /** * Provision a new tenant with database and default data */ async provisionTenant(data: { name: string; slug: string; primaryDomain: string; dbHost?: string; dbPort?: number; }) { const dbHost = data.dbHost || process.env.DB_HOST || 'platform-db'; const dbPort = data.dbPort || parseInt(process.env.DB_PORT || '3306'); const dbName = `tenant_${data.slug}`; const dbUsername = `tenant_${data.slug}_user`; const dbPassword = this.generateSecurePassword(); this.logger.log(`Provisioning tenant: ${data.name} (${data.slug})`); try { // Step 1: Create MySQL database and user await this.createTenantDatabase( dbHost, dbPort, dbName, dbUsername, dbPassword, ); // Step 2: Run migrations on new tenant database await this.runTenantMigrations( dbHost, dbPort, dbName, dbUsername, dbPassword, ); // Step 3: Store tenant info in central database const centralPrisma = getCentralPrisma(); const tenant = await centralPrisma.tenant.create({ data: { name: data.name, slug: data.slug, dbHost, dbPort, dbName, dbUsername, dbPassword: this.tenantDbService.encryptPassword(dbPassword), status: 'active', domains: { create: { domain: data.primaryDomain, isPrimary: true, }, }, }, include: { domains: true, }, }); this.logger.log(`Tenant provisioned successfully: ${tenant.id}`); // Step 4: Seed default data (admin user, default roles, etc.) await this.seedDefaultData(tenant.id); return { tenantId: tenant.id, dbName, dbUsername, dbPassword, // Return for initial setup, should be stored securely }; } catch (error) { this.logger.error(`Failed to provision tenant: ${data.slug}`, error); // Attempt cleanup await this.rollbackProvisioning(dbHost, dbPort, dbName, dbUsername).catch( (cleanupError) => { this.logger.error( 'Failed to cleanup after provisioning error', cleanupError, ); }, ); throw error; } } /** * Create MySQL database and user */ private async createTenantDatabase( host: string, port: number, dbName: string, username: string, password: string, ) { // Connect as root to create database and user const rootKnex = knex.default({ client: 'mysql2', connection: { host, port, user: process.env.DB_ROOT_USER || 'root', password: process.env.DB_ROOT_PASSWORD || 'root', }, }); try { // Create database await rootKnex.raw( `CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`, ); this.logger.log(`Database created: ${dbName}`); // Create user and grant privileges await rootKnex.raw( `CREATE USER IF NOT EXISTS '${username}'@'%' IDENTIFIED BY '${password}'`, ); await rootKnex.raw( `GRANT ALL PRIVILEGES ON \`${dbName}\`.* TO '${username}'@'%'`, ); await rootKnex.raw('FLUSH PRIVILEGES'); this.logger.log(`User created: ${username}`); } finally { await rootKnex.destroy(); } } /** * Run Knex migrations on tenant database */ private async runTenantMigrations( host: string, port: number, dbName: string, username: string, password: string, ) { const tenantKnex = knex.default({ client: 'mysql2', connection: { host, port, database: dbName, user: username, password, }, migrations: { directory: './migrations/tenant', tableName: 'knex_migrations', }, }); try { await tenantKnex.migrate.latest(); this.logger.log(`Migrations completed for database: ${dbName}`); } finally { await tenantKnex.destroy(); } } /** * Seed default data for new tenant */ private async seedDefaultData(tenantId: string) { const tenantKnex = await this.tenantDbService.getTenantKnex(tenantId); try { // Create default roles const adminRoleId = crypto.randomUUID(); await tenantKnex('roles').insert({ id: adminRoleId, name: 'Admin', guardName: 'api', description: 'Full system administrator access', created_at: tenantKnex.fn.now(), updated_at: tenantKnex.fn.now(), }); const userRoleId = crypto.randomUUID(); await tenantKnex('roles').insert({ id: userRoleId, name: 'User', guardName: 'api', description: 'Standard user access', created_at: tenantKnex.fn.now(), updated_at: tenantKnex.fn.now(), }); // Create default permissions const permissions = [ { name: 'manage_users', description: 'Manage users' }, { name: 'manage_roles', description: 'Manage roles and permissions' }, { name: 'manage_apps', description: 'Manage applications' }, { name: 'manage_objects', description: 'Manage object definitions' }, { name: 'view_data', description: 'View data' }, { name: 'create_data', description: 'Create data' }, { name: 'edit_data', description: 'Edit data' }, { name: 'delete_data', description: 'Delete data' }, ]; for (const perm of permissions) { await tenantKnex('permissions').insert({ id: crypto.randomUUID(), name: perm.name, guardName: 'api', description: perm.description, created_at: tenantKnex.fn.now(), updated_at: tenantKnex.fn.now(), }); } // Grant all permissions to Admin role const allPermissions = await tenantKnex('permissions').select('id'); for (const perm of allPermissions) { await tenantKnex('role_permissions').insert({ id: crypto.randomUUID(), roleId: adminRoleId, permissionId: perm.id, created_at: tenantKnex.fn.now(), updated_at: tenantKnex.fn.now(), }); } // Grant view/create/edit permissions to User role const userPermissions = await tenantKnex('permissions') .whereIn('name', ['view_data', 'create_data', 'edit_data']) .select('id'); for (const perm of userPermissions) { await tenantKnex('role_permissions').insert({ id: crypto.randomUUID(), roleId: userRoleId, permissionId: perm.id, created_at: tenantKnex.fn.now(), updated_at: tenantKnex.fn.now(), }); } this.logger.log(`Default data seeded for tenant: ${tenantId}`); } catch (error) { this.logger.error( `Failed to seed default data for tenant: ${tenantId}`, error, ); throw error; } } /** * Rollback provisioning in case of error */ private async rollbackProvisioning( host: string, port: number, dbName: string, username: string, ) { const rootKnex = knex.default({ client: 'mysql2', connection: { host, port, user: process.env.DB_ROOT_USER || 'root', password: process.env.DB_ROOT_PASSWORD || 'root', }, }); try { await rootKnex.raw(`DROP DATABASE IF EXISTS \`${dbName}\``); await rootKnex.raw(`DROP USER IF EXISTS '${username}'@'%'`); this.logger.log(`Rolled back provisioning for database: ${dbName}`); } finally { await rootKnex.destroy(); } } /** * Generate secure random password */ private generateSecurePassword(): string { return crypto.randomBytes(32).toString('base64').slice(0, 32); } /** * Deprovision a tenant (delete database and central record) */ async deprovisionTenant(tenantId: string) { const centralPrisma = getCentralPrisma(); const tenant = await centralPrisma.tenant.findUnique({ where: { id: tenantId }, }); if (!tenant) { throw new Error(`Tenant not found: ${tenantId}`); } try { // Delete tenant database const rootKnex = knex.default({ client: 'mysql2', connection: { host: tenant.dbHost, port: tenant.dbPort, user: process.env.DB_ROOT_USER || 'root', password: process.env.DB_ROOT_PASSWORD || 'root', }, }); try { await rootKnex.raw(`DROP DATABASE IF EXISTS \`${tenant.dbName}\``); await rootKnex.raw(`DROP USER IF EXISTS '${tenant.dbUsername}'@'%'`); this.logger.log(`Database deleted: ${tenant.dbName}`); } finally { await rootKnex.destroy(); } // Delete tenant from central database await centralPrisma.tenant.delete({ where: { id: tenantId }, }); // Remove from connection cache this.tenantDbService.removeTenantConnection(tenantId); this.logger.log(`Tenant deprovisioned: ${tenantId}`); } catch (error) { this.logger.error(`Failed to deprovision tenant: ${tenantId}`, error); throw error; } } }