Added auth functionality, initial work with views and field types

This commit is contained in:
Francisco Gaona
2025-12-22 03:31:55 +01:00
parent 859dca6c84
commit 0fe56c0e03
170 changed files with 11599 additions and 435 deletions

194
backend/scripts/README.md Normal file
View File

@@ -0,0 +1,194 @@
# Tenant Migration Scripts
This directory contains scripts for managing database migrations across all tenants in the multi-tenant platform.
## Available Scripts
### 1. Create a New Migration
```bash
npm run migrate:make <migration_name>
```
Creates a new migration file in `migrations/tenant/` directory.
**Example:**
```bash
npm run migrate:make add_status_field_to_contacts
```
### 2. Migrate a Single Tenant
```bash
npm run migrate:tenant <tenant-slug-or-id>
```
Runs all pending migrations for a specific tenant. You can identify the tenant by its slug or ID.
**Example:**
```bash
npm run migrate:tenant acme-corp
npm run migrate:tenant cm5a1b2c3d4e5f6g7h8i9j0k
```
### 3. Migrate All Tenants
```bash
npm run migrate:all-tenants
```
Runs all pending migrations for **all active tenants** in the system. This is useful when:
- You've created a new migration that needs to be applied to all tenants
- You're updating the schema across the entire platform
- You need to ensure all tenants are up to date
**Output:**
- Shows progress for each tenant
- Lists which migrations were applied
- Provides a summary at the end
- Exits with error code if any tenant fails
### 4. Rollback Migration (Manual)
```bash
npm run migrate:rollback
```
⚠️ **Warning:** This runs a rollback on the **default database** configured in `knexfile.js`. For tenant-specific rollbacks, you'll need to manually configure the connection.
## Migration Flow
### During New Tenant Provisioning
When a new tenant is created via the API, migrations are automatically run as part of the provisioning process:
1. Tenant database is created
2. `TenantProvisioningService.runTenantMigrations()` is called
3. All migrations in `migrations/tenant/` are executed
### For Existing Tenants
When you add a new migration file and need to apply it to existing tenants:
1. Create the migration:
```bash
npm run migrate:make add_new_feature
```
2. Edit the generated migration file in `migrations/tenant/`
3. Test on a single tenant first:
```bash
npm run migrate:tenant test-tenant
```
4. If successful, apply to all tenants:
```bash
npm run migrate:all-tenants
```
## Migration Directory Structure
```
backend/
├── migrations/
│ └── tenant/ # Tenant-specific migrations
│ ├── 20250126000001_create_users_and_rbac.js
│ ├── 20250126000002_create_object_definitions.js
│ └── ...
├── scripts/
│ ├── migrate-tenant.ts # Single tenant migration
│ └── migrate-all-tenants.ts # All tenants migration
└── knexfile.js # Knex configuration
```
## Security Notes
### Database Password Encryption
Tenant database passwords are encrypted in the central database using AES-256-CBC encryption. The migration scripts automatically:
1. Fetch tenant connection details from the central database
2. Decrypt the database password using the `DB_ENCRYPTION_KEY` environment variable
3. Connect to the tenant database
4. Run migrations
5. Close the connection
**Required Environment Variable:**
```bash
DB_ENCRYPTION_KEY=your-32-character-secret-key!!
```
This key must match the key used by `TenantService` for encryption.
## Troubleshooting
### Migration Fails for One Tenant
If `migrate:all-tenants` fails for a specific tenant:
1. Check the error message in the output
2. Investigate the tenant's database directly
3. Fix the issue (manual SQL, data cleanup, etc.)
4. Re-run migrations for that tenant: `npm run migrate:tenant <slug>`
5. Once fixed, run `migrate:all-tenants` again to ensure others are updated
### Migration Already Exists
Knex tracks which migrations have been run in the `knex_migrations` table in each tenant database. If a migration was already applied, it will be skipped automatically.
### Connection Issues
If you see connection errors:
1. Verify the central database is accessible
2. Check that tenant database credentials are correct
3. Ensure `DB_ENCRYPTION_KEY` matches the one used for encryption
4. Verify the tenant's database server is running and accessible
## Example Migration File
```javascript
// migrations/tenant/20250126000006_add_custom_fields.js
exports.up = async function(knex) {
await knex.schema.table('field_definitions', (table) => {
table.boolean('is_custom').defaultTo(false);
table.string('custom_type', 50).nullable();
});
};
exports.down = async function(knex) {
await knex.schema.table('field_definitions', (table) => {
table.dropColumn('is_custom');
table.dropColumn('custom_type');
});
};
```
## Best Practices
1. **Always test on a single tenant first** before running migrations on all tenants
2. **Include rollback logic** in your `down()` function
3. **Use transactions** for complex multi-step migrations
4. **Backup production databases** before running migrations
5. **Monitor the output** when running `migrate:all-tenants` to catch any failures
6. **Version control** your migration files
7. **Document breaking changes** in migration comments
8. **Consider data migrations** separately from schema migrations when dealing with large datasets
## CI/CD Integration
In your deployment pipeline, you can automatically migrate all tenants:
```bash
# After deploying new code
npm run migrate:all-tenants
```
Or integrate it into your Docker deployment:
```dockerfile
# In your Dockerfile or docker-compose.yml
CMD npm run migrate:all-tenants && npm run start:prod
```

View File

@@ -0,0 +1,181 @@
import { PrismaClient as CentralPrismaClient } from '.prisma/central';
import knex, { Knex } from 'knex';
import { createDecipheriv } from 'crypto';
// Encryption configuration
const ALGORITHM = 'aes-256-cbc';
/**
* Decrypt a tenant's database password
*/
function decryptPassword(encryptedPassword: string): string {
try {
// Check if password is already plaintext (for legacy/development)
if (!encryptedPassword.includes(':')) {
return encryptedPassword;
}
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const parts = encryptedPassword.split(':');
if (parts.length !== 2) {
throw new Error('Invalid encrypted password format');
}
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = createDecipheriv(ALGORITHM, key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
console.error('Error decrypting password:', error);
throw error;
}
}
/**
* Create a Knex connection for a specific tenant
*/
function createTenantKnexConnection(tenant: any): Knex {
const decryptedPassword = decryptPassword(tenant.dbPassword);
return knex({
client: 'mysql2',
connection: {
host: tenant.dbHost,
port: tenant.dbPort,
user: tenant.dbUsername,
password: decryptedPassword,
database: tenant.dbName,
},
migrations: {
tableName: 'knex_migrations',
directory: './migrations/tenant',
},
});
}
/**
* Get migration status for a specific tenant
*/
async function getTenantMigrationStatus(tenant: any): Promise<{
completed: string[];
pending: string[];
}> {
const tenantKnex = createTenantKnexConnection(tenant);
try {
const [completed, pending] = await tenantKnex.migrate.list();
return {
completed: completed[1] || [],
pending: pending || [],
};
} catch (error) {
throw error;
} finally {
await tenantKnex.destroy();
}
}
/**
* Check migration status across all tenants
*/
async function checkMigrationStatus() {
console.log('🔍 Checking migration status for all tenants...\n');
const centralPrisma = new CentralPrismaClient();
try {
// Fetch all active tenants
const tenants = await centralPrisma.tenant.findMany({
where: {
status: 'ACTIVE',
},
orderBy: {
name: 'asc',
},
});
if (tenants.length === 0) {
console.log('⚠️ No active tenants found.');
return;
}
console.log(`📋 Found ${tenants.length} active tenant(s)\n`);
console.log('='.repeat(80));
let allUpToDate = true;
const tenantsWithPending: { name: string; pending: string[] }[] = [];
// Check each tenant
for (const tenant of tenants) {
try {
const status = await getTenantMigrationStatus(tenant);
console.log(`\n📦 ${tenant.name} (${tenant.slug})`);
console.log(` Database: ${tenant.dbName}`);
console.log(` Completed: ${status.completed.length} migration(s)`);
if (status.pending.length > 0) {
allUpToDate = false;
console.log(` ⚠️ Pending: ${status.pending.length} migration(s)`);
status.pending.forEach((migration) => {
console.log(` - ${migration}`);
});
tenantsWithPending.push({
name: tenant.name,
pending: status.pending,
});
} else {
console.log(` ✅ Up to date`);
}
// Show last 3 completed migrations
if (status.completed.length > 0) {
const recent = status.completed.slice(-3);
console.log(` Recent migrations:`);
recent.forEach((migration) => {
console.log(` - ${migration}`);
});
}
} catch (error) {
console.log(`\n❌ ${tenant.name}: Failed to check status`);
console.log(` Error: ${error.message}`);
allUpToDate = false;
}
}
// Print summary
console.log('\n' + '='.repeat(80));
console.log('📊 Summary');
console.log('='.repeat(80));
if (allUpToDate) {
console.log('✅ All tenants are up to date!');
} else {
console.log(`⚠️ ${tenantsWithPending.length} tenant(s) have pending migrations:\n`);
tenantsWithPending.forEach(({ name, pending }) => {
console.log(` ${name}: ${pending.length} pending`);
});
console.log('\n💡 Run: npm run migrate:all-tenants');
}
} catch (error) {
console.error('❌ Fatal error:', error);
process.exit(1);
} finally {
await centralPrisma.$disconnect();
}
}
// Run the status check
checkMigrationStatus()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,50 @@
import { PrismaClient as CentralPrismaClient } from '../node_modules/.prisma/central';
import * as bcrypt from 'bcrypt';
// Central database client
const centralPrisma = new CentralPrismaClient();
async function createAdminUser() {
const email = 'admin@example.com';
const password = 'admin123';
const firstName = 'Admin';
const lastName = 'User';
try {
// Check if admin user already exists
const existingUser = await centralPrisma.user.findUnique({
where: { email },
});
if (existingUser) {
console.log(`User ${email} already exists`);
return;
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create admin user in central database
const user = await centralPrisma.user.create({
data: {
email,
password: hashedPassword,
firstName,
lastName,
role: 'superadmin',
isActive: true,
},
});
console.log('\nAdmin user created successfully!');
console.log('Email:', email);
console.log('Password:', password);
console.log('User ID:', user.id);
} catch (error) {
console.error('Error creating admin user:', error);
} finally {
await centralPrisma.$disconnect();
}
}
createAdminUser();

View File

@@ -0,0 +1,138 @@
import { PrismaClient as CentralPrismaClient } from '../node_modules/.prisma/central';
import * as bcrypt from 'bcrypt';
import { Knex, knex } from 'knex';
// Central database client
const centralPrisma = new CentralPrismaClient();
async function createTenantUser() {
const tenantSlug = 'tenant1';
const email = 'user@example.com';
const password = 'user123';
const firstName = 'Test';
const lastName = 'User';
try {
// Get tenant database connection info
const tenant = await centralPrisma.tenant.findFirst({
where: { slug: tenantSlug },
});
if (!tenant) {
console.log(`Tenant ${tenantSlug} not found. Creating tenant...`);
// Create tenant in central database
const newTenant = await centralPrisma.tenant.create({
data: {
name: 'Default Tenant',
slug: tenantSlug,
dbHost: 'db',
dbPort: 3306,
dbName: 'platform',
dbUsername: 'platform',
dbPassword: 'platform',
status: 'active',
},
});
console.log('Tenant created:', newTenant.slug);
} else {
console.log('Tenant found:', tenant.slug);
}
const tenantInfo = tenant || {
dbHost: 'db',
dbPort: 3306,
dbName: 'platform',
dbUsername: 'platform',
dbPassword: 'platform',
};
// Connect to tenant database (using root for now since tenant password is encrypted)
const tenantDb: Knex = knex({
client: 'mysql2',
connection: {
host: tenantInfo.dbHost,
port: tenantInfo.dbPort,
database: tenantInfo.dbName,
user: 'root',
password: 'asjdnfqTash37faggT',
},
});
// Check if user already exists
const existingUser = await tenantDb('users')
.where({ email })
.first();
if (existingUser) {
console.log(`User ${email} already exists in tenant ${tenantSlug}`);
await tenantDb.destroy();
return;
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
await tenantDb('users').insert({
email,
password: hashedPassword,
firstName,
lastName,
isActive: true,
created_at: new Date(),
updated_at: new Date(),
});
console.log(`\nUser created successfully in tenant ${tenantSlug}!`);
console.log('Email:', email);
console.log('Password:', password);
// Create admin role if it doesn't exist
let adminRole = await tenantDb('roles')
.where({ name: 'admin' })
.first();
if (!adminRole) {
await tenantDb('roles').insert({
name: 'admin',
guardName: 'api',
description: 'Administrator role with full access',
created_at: new Date(),
updated_at: new Date(),
});
adminRole = await tenantDb('roles')
.where({ name: 'admin' })
.first();
console.log('Admin role created');
}
// Get the created user
const user = await tenantDb('users')
.where({ email })
.first();
// Assign admin role to user
if (adminRole && user) {
await tenantDb('user_roles').insert({
userId: user.id,
roleId: adminRole.id,
created_at: new Date(),
updated_at: new Date(),
});
console.log('Admin role assigned to user');
}
await tenantDb.destroy();
} catch (error) {
console.error('Error creating tenant user:', error);
} finally {
await centralPrisma.$disconnect();
}
}
createTenantUser();

View File

@@ -0,0 +1,165 @@
import { PrismaClient as CentralPrismaClient } from '.prisma/central';
import knex, { Knex } from 'knex';
import { createDecipheriv } from 'crypto';
// Encryption configuration - must match the one used in tenant service
const ALGORITHM = 'aes-256-cbc';
/**
* Decrypt a tenant's database password
*/
function decryptPassword(encryptedPassword: string): string {
try {
// Check if password is already plaintext (for legacy/development)
if (!encryptedPassword.includes(':')) {
console.warn('⚠️ Password appears to be unencrypted, using as-is');
return encryptedPassword;
}
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const parts = encryptedPassword.split(':');
if (parts.length !== 2) {
throw new Error('Invalid encrypted password format');
}
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = createDecipheriv(ALGORITHM, key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
console.error('Error decrypting password:', error);
throw error;
}
}
/**
* Create a Knex connection for a specific tenant
*/
function createTenantKnexConnection(tenant: any): Knex {
const decryptedPassword = decryptPassword(tenant.dbPassword);
return knex({
client: 'mysql2',
connection: {
host: tenant.dbHost,
port: tenant.dbPort,
user: tenant.dbUsername,
password: decryptedPassword,
database: tenant.dbName,
},
migrations: {
tableName: 'knex_migrations',
directory: './migrations/tenant',
},
});
}
/**
* Run migrations for a specific tenant
*/
async function migrateTenant(tenant: any): Promise<void> {
console.log(`\n🔄 Migrating tenant: ${tenant.name} (${tenant.dbName})`);
const tenantKnex = createTenantKnexConnection(tenant);
try {
const [batchNo, log] = await tenantKnex.migrate.latest();
if (log.length === 0) {
console.log(`${tenant.name}: Already up to date`);
} else {
console.log(`${tenant.name}: Ran ${log.length} migrations:`);
log.forEach((migration) => {
console.log(` - ${migration}`);
});
}
} catch (error) {
console.error(`${tenant.name}: Migration failed:`, error.message);
throw error;
} finally {
await tenantKnex.destroy();
}
}
/**
* Main function to migrate all active tenants
*/
async function migrateAllTenants() {
console.log('🚀 Starting migration for all tenants...\n');
const centralPrisma = new CentralPrismaClient();
try {
// Fetch all active tenants
const tenants = await centralPrisma.tenant.findMany({
where: {
status: 'ACTIVE',
},
orderBy: {
name: 'asc',
},
});
if (tenants.length === 0) {
console.log('⚠️ No active tenants found.');
return;
}
console.log(`📋 Found ${tenants.length} active tenant(s)\n`);
let successCount = 0;
let failureCount = 0;
const failures: { tenant: string; error: string }[] = [];
// Migrate each tenant sequentially
for (const tenant of tenants) {
try {
await migrateTenant(tenant);
successCount++;
} catch (error) {
failureCount++;
failures.push({
tenant: tenant.name,
error: error.message,
});
}
}
// Print summary
console.log('\n' + '='.repeat(60));
console.log('📊 Migration Summary');
console.log('='.repeat(60));
console.log(`✅ Successful: ${successCount}`);
console.log(`❌ Failed: ${failureCount}`);
if (failures.length > 0) {
console.log('\n❌ Failed Tenants:');
failures.forEach(({ tenant, error }) => {
console.log(` - ${tenant}: ${error}`);
});
process.exit(1);
} else {
console.log('\n🎉 All tenant migrations completed successfully!');
}
} catch (error) {
console.error('❌ Fatal error:', error);
process.exit(1);
} finally {
await centralPrisma.$disconnect();
}
}
// Run the migration
migrateAllTenants()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,134 @@
import { PrismaClient as CentralPrismaClient } from '.prisma/central';
import knex, { Knex } from 'knex';
import { createDecipheriv } from 'crypto';
// Encryption configuration
const ALGORITHM = 'aes-256-cbc';
/**
* Decrypt a tenant's database password
*/
function decryptPassword(encryptedPassword: string): string {
try {
// Check if password is already plaintext (for legacy/development)
if (!encryptedPassword.includes(':')) {
console.warn('⚠️ Password appears to be unencrypted, using as-is');
return encryptedPassword;
}
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const parts = encryptedPassword.split(':');
if (parts.length !== 2) {
throw new Error('Invalid encrypted password format');
}
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = createDecipheriv(ALGORITHM, key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
console.error('Error decrypting password:', error);
throw error;
}
}
/**
* Create a Knex connection for a specific tenant
*/
function createTenantKnexConnection(tenant: any): Knex {
const decryptedPassword = decryptPassword(tenant.dbPassword);
return knex({
client: 'mysql2',
connection: {
host: tenant.dbHost,
port: tenant.dbPort,
user: tenant.dbUsername,
password: decryptedPassword,
database: tenant.dbName,
},
migrations: {
tableName: 'knex_migrations',
directory: './migrations/tenant',
},
});
}
/**
* Migrate a specific tenant by slug or ID
*/
async function migrateTenant() {
const tenantIdentifier = process.argv[2];
if (!tenantIdentifier) {
console.error('❌ Usage: npm run migrate:tenant <tenant-slug-or-id>');
process.exit(1);
}
console.log(`🔍 Looking for tenant: ${tenantIdentifier}\n`);
const centralPrisma = new CentralPrismaClient();
try {
// Find tenant by slug or ID
const tenant = await centralPrisma.tenant.findFirst({
where: {
OR: [
{ slug: tenantIdentifier },
{ id: tenantIdentifier },
],
},
});
if (!tenant) {
console.error(`❌ Tenant not found: ${tenantIdentifier}`);
process.exit(1);
}
console.log(`📋 Tenant: ${tenant.name} (${tenant.slug})`);
console.log(`📊 Database: ${tenant.dbName}`);
console.log(`🔄 Running migrations...\n`);
const tenantKnex = createTenantKnexConnection(tenant);
try {
const [batchNo, log] = await tenantKnex.migrate.latest();
if (log.length === 0) {
console.log(`✅ Already up to date (batch ${batchNo})`);
} else {
console.log(`✅ Ran ${log.length} migration(s) (batch ${batchNo}):`);
log.forEach((migration) => {
console.log(` - ${migration}`);
});
}
console.log('\n🎉 Migration completed successfully!');
} catch (error) {
console.error('❌ Migration failed:', error.message);
throw error;
} finally {
await tenantKnex.destroy();
}
} catch (error) {
console.error('❌ Fatal error:', error);
process.exit(1);
} finally {
await centralPrisma.$disconnect();
}
}
// Run the migration
migrateTenant()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});