Files
drone-detector/server/migrations/20250912000001-add-multi-tenant-support.js
2025-09-20 23:20:26 +02:00

381 lines
12 KiB
JavaScript

/**
* Migration: Add Multi-Tenant Support
* Adds tenant table and updates user table for multi-tenancy
*/
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Create tenants table (idempotent)
try {
await queryInterface.createTable('tenants', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true
},
name: {
type: Sequelize.STRING,
allowNull: false,
comment: 'Human-readable tenant name'
},
slug: {
type: Sequelize.STRING,
allowNull: false,
unique: true,
comment: 'URL-safe tenant identifier'
},
domain: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Custom domain for this tenant'
},
subscription_type: {
type: Sequelize.ENUM('free', 'basic', 'premium', 'enterprise'),
defaultValue: 'basic',
comment: 'Subscription tier'
},
is_active: {
type: Sequelize.BOOLEAN,
defaultValue: true,
comment: 'Whether tenant is active'
},
auth_provider: {
type: Sequelize.ENUM('local', 'saml', 'oauth', 'ldap', 'custom_sso'),
defaultValue: 'local',
comment: 'Primary authentication provider'
},
auth_config: {
type: Sequelize.JSONB,
allowNull: true,
comment: 'Authentication provider configuration'
},
user_mapping: {
type: Sequelize.JSONB,
allowNull: true,
comment: 'User attribute mapping from external provider'
},
role_mapping: {
type: Sequelize.JSONB,
allowNull: true,
comment: 'Role mapping from external provider to internal roles'
},
branding: {
type: Sequelize.JSONB,
allowNull: true,
comment: 'Tenant-specific branding'
},
features: {
type: Sequelize.JSONB,
defaultValue: {
max_devices: 10,
max_users: 5,
api_rate_limit: 1000,
data_retention_days: 90,
features: ['basic_detection', 'alerts', 'dashboard']
},
comment: 'Tenant feature limits and enabled features'
},
admin_email: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Primary admin email for this tenant'
},
admin_phone: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Primary admin phone for this tenant'
},
billing_email: {
type: Sequelize.STRING,
allowNull: true
},
payment_method_id: {
type: Sequelize.STRING,
allowNull: true,
comment: 'Payment provider customer ID'
},
metadata: {
type: Sequelize.JSONB,
allowNull: true,
comment: 'Additional tenant metadata'
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
updated_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
}
});
console.log('✅ Created tenants table');
} catch (error) {
if (error.parent?.code === '42P07') { // Table already exists
console.log('⚠️ Tenants table already exists, skipping...');
} else {
throw error;
}
}
// Add indexes to tenants table (idempotent)
try {
await queryInterface.addIndex('tenants', ['slug'], { unique: true });
console.log('✅ Added unique index on tenants.slug');
} catch (error) {
if (error.parent?.code === '42P07') { // Index already exists
console.log('⚠️ Index tenants_slug already exists, skipping...');
} else {
throw error;
}
}
try {
await queryInterface.addIndex('tenants', ['domain'], {
unique: true,
where: { domain: { [Sequelize.Op.ne]: null } }
});
console.log('✅ Added unique index on tenants.domain');
} catch (error) {
if (error.parent?.code === '42P07') { // Index already exists
console.log('⚠️ Index tenants_domain already exists, skipping...');
} else {
throw error;
}
}
try {
await queryInterface.addIndex('tenants', ['is_active']);
console.log('✅ Added index on tenants.is_active');
} catch (error) {
if (error.parent?.code === '42P07') { // Index already exists
console.log('⚠️ Index tenants_is_active already exists, skipping...');
} else {
throw error;
}
}
try {
await queryInterface.addIndex('tenants', ['auth_provider']);
console.log('✅ Added index on tenants.auth_provider');
} catch (error) {
if (error.parent?.code === '42P07') { // Index already exists
console.log('⚠️ Index tenants_auth_provider already exists, skipping...');
} else {
throw error;
}
}
// Add tenant-related columns to users table (idempotent)
const tables = await queryInterface.showAllTables();
if (!tables.includes('users')) {
console.log('⚠️ Users table does not exist yet, skipping user tenant columns migration...');
} else {
const usersTableDescription = await queryInterface.describeTable('users');
if (!usersTableDescription.tenant_id) {
await queryInterface.addColumn('users', 'tenant_id', {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'tenants',
key: 'id'
},
comment: 'Tenant this user belongs to'
});
console.log('✅ Added tenant_id column to users table');
} else {
console.log('⚠️ Column tenant_id already exists in users table, skipping...');
}
if (!usersTableDescription.external_provider) {
await queryInterface.addColumn('users', 'external_provider', {
type: Sequelize.ENUM('local', 'saml', 'oauth', 'ldap', 'custom_sso'),
defaultValue: 'local',
comment: 'Authentication provider used for this user'
});
console.log('✅ Added external_provider column to users table');
} else {
console.log('⚠️ Column external_provider already exists in users table, skipping...');
}
if (!usersTableDescription.external_id) {
await queryInterface.addColumn('users', 'external_id', {
type: Sequelize.STRING,
allowNull: true,
comment: 'User ID from external authentication provider'
});
console.log('✅ Added external_id column to users table');
} else {
console.log('⚠️ Column external_id already exists in users table, skipping...');
}
// Add indexes to users table (idempotent)
try {
await queryInterface.addIndex('users', ['tenant_id']);
console.log('✅ Added index on users.tenant_id');
} catch (error) {
if (error.parent?.code === '42P07') { // Index already exists
console.log('⚠️ Index users_tenant_id already exists, skipping...');
} else {
throw error;
}
}
try {
await queryInterface.addIndex('users', ['external_provider']);
console.log('✅ Added index on users.external_provider');
} catch (error) {
if (error.parent?.code === '42P07') { // Index already exists
console.log('⚠️ Index users_external_provider already exists, skipping...');
} else {
throw error;
}
}
try {
await queryInterface.addIndex('users', ['external_id', 'tenant_id'], {
unique: true,
name: 'users_external_id_tenant_unique',
where: { external_id: { [Sequelize.Op.ne]: null } }
});
console.log('✅ Added unique index on users.external_id + tenant_id');
} catch (error) {
if (error.parent?.code === '42P07') { // Index already exists
console.log('⚠️ Index users_external_id_tenant_unique already exists, skipping...');
} else {
throw error;
}
}
// Create default tenant for backward compatibility (only if it doesn't exist)
const [existingTenant] = await queryInterface.sequelize.query(
'SELECT id FROM tenants WHERE slug = \'default\' LIMIT 1',
{ type: Sequelize.QueryTypes.SELECT }
);
let defaultTenantId;
if (!existingTenant) {
console.log('🏢 Creating default tenant...');
defaultTenantId = await queryInterface.bulkInsert('tenants', [{
id: Sequelize.literal('gen_random_uuid()'),
name: 'Default Organization',
slug: 'default',
subscription_type: 'enterprise',
is_active: true,
auth_provider: 'local',
features: JSON.stringify({
max_devices: -1,
max_users: -1,
api_rate_limit: 50000,
data_retention_days: -1,
features: ['all']
}),
created_at: new Date(),
updated_at: new Date()
}], { returning: true });
} else {
console.log('⚠️ Default tenant already exists, skipping...');
defaultTenantId = existingTenant.id;
}
// Associate existing users with default tenant
await queryInterface.sequelize.query(`
UPDATE users
SET tenant_id = (SELECT id FROM tenants WHERE slug = 'default')
WHERE tenant_id IS NULL
`);
// Add tenant_id to devices table if it exists
try {
await queryInterface.addColumn('devices', 'tenant_id', {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'tenants',
key: 'id'
},
comment: 'Tenant this device belongs to'
});
await queryInterface.addIndex('devices', ['tenant_id']);
// Associate existing devices with default tenant
await queryInterface.sequelize.query(`
UPDATE devices
SET tenant_id = (SELECT id FROM tenants WHERE slug = 'default')
WHERE tenant_id IS NULL
`);
} catch (error) {
console.log('Devices table not found or already has tenant_id column');
}
// Add tenant_id to alert_rules table if it exists
try {
await queryInterface.addColumn('alert_rules', 'tenant_id', {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'tenants',
key: 'id'
},
comment: 'Tenant this alert rule belongs to'
});
await queryInterface.addIndex('alert_rules', ['tenant_id']);
// Associate existing alert rules with default tenant
await queryInterface.sequelize.query(`
UPDATE alert_rules
SET tenant_id = (SELECT id FROM tenants WHERE slug = 'default')
WHERE tenant_id IS NULL
`);
} catch (error) {
console.log('Alert_rules table not found or already has tenant_id column');
}
} // Close the else block for users table check
console.log('✅ Multi-tenant support added successfully');
console.log('✅ Default tenant created for backward compatibility');
console.log('✅ Existing data associated with default tenant');
},
async down(queryInterface, Sequelize) {
// Remove indexes from users table
await queryInterface.removeIndex('users', ['tenant_id']);
await queryInterface.removeIndex('users', ['external_provider']);
await queryInterface.removeIndex('users', 'users_external_id_tenant_unique');
// Remove columns from users table
await queryInterface.removeColumn('users', 'tenant_id');
await queryInterface.removeColumn('users', 'external_provider');
await queryInterface.removeColumn('users', 'external_id');
// Remove tenant_id from other tables
try {
await queryInterface.removeColumn('devices', 'tenant_id');
} catch (error) {
console.log('Devices table tenant_id column not found');
}
try {
await queryInterface.removeColumn('alert_rules', 'tenant_id');
} catch (error) {
console.log('Alert_rules table tenant_id column not found');
}
// Drop ENUMs
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_users_external_provider"');
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_tenants_auth_provider"');
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_tenants_subscription_type"');
// Drop tenants table
await queryInterface.dropTable('tenants');
console.log('✅ Multi-tenant support removed');
}
};