345 lines
9.6 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|