Files
neo/backend/src/tenant/tenant-provisioning.service.ts
2026-01-05 07:59:02 +01:00

345 lines
9.6 KiB
TypeScript

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.getTenantKnexById(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;
}
}
}