UNPKG

supa-seed

Version:

A constraint-aware, framework-agnostic database seeding framework with deep PostgreSQL business logic discovery and MakerKit integration support

498 lines 19.4 kB
"use strict"; /** * Multi-Tenant Data Manager * Handles tenant-scoped data creation, validation, and isolation */ Object.defineProperty(exports, "__esModule", { value: true }); exports.MultiTenantManager = void 0; const logger_1 = require("../core/utils/logger"); const tenant_types_1 = require("./tenant-types"); class MultiTenantManager { constructor(client, config = {}) { this.detectedTenantScopes = new Map(); this.tenantCache = new Map(); this.client = client; this.config = { enableMultiTenant: true, tenantColumn: 'account_id', tenantScopeDetection: 'auto', manualTenantScopes: {}, validationEnabled: true, strictIsolation: true, allowSharedResources: true, dataGenerationOptions: tenant_types_1.DEFAULT_TENANT_GENERATION_OPTIONS, ...config }; } /** * Discover tenant-scoped tables in the database */ async discoverTenantScopes() { logger_1.Logger.info('🏢 Discovering tenant-scoped tables...'); try { const tenantScopedTables = []; const sharedTables = []; let totalConfidence = 0; // Get all tables in the schema const { data: tables, error } = await this.client .from('information_schema.tables') .select('table_name, table_schema') .eq('table_schema', 'public') .eq('table_type', 'BASE TABLE'); if (error || !tables) { throw new Error(`Failed to get table list: ${error?.message}`); } // Analyze each table for tenant scope indicators for (const table of tables) { const tableName = table.table_name; // Skip system tables if (tableName.startsWith('_') || tableName.includes('schema_migrations')) { continue; } const scopeInfo = await this.analyzeTenantScope(tableName, 'public'); if (scopeInfo.isTenantScoped) { tenantScopedTables.push(scopeInfo); this.detectedTenantScopes.set(tableName, scopeInfo); totalConfidence += scopeInfo.confidence; logger_1.Logger.debug(`✅ Tenant-scoped table detected: ${tableName} (${scopeInfo.tenantColumn})`); } else { sharedTables.push(tableName); logger_1.Logger.debug(`📋 Shared table: ${tableName}`); } } const averageConfidence = tenantScopedTables.length > 0 ? totalConfidence / tenantScopedTables.length : 0; const recommendations = this.generateTenantRecommendations(tenantScopedTables, sharedTables); logger_1.Logger.success(`🏢 Tenant scope discovery completed: ${tenantScopedTables.length} tenant-scoped tables found`); return { success: true, tenantScopedTables, sharedTables, tenantColumn: this.config.tenantColumn, confidence: averageConfidence, recommendations, errors: [], warnings: [], metadata: { totalTables: tables.length, tenantScopedCount: tenantScopedTables.length, sharedCount: sharedTables.length, averageConfidence, detectionMethod: 'column_analysis' } }; } catch (error) { logger_1.Logger.error('Tenant scope discovery failed:', error); return { success: false, tenantScopedTables: [], sharedTables: [], tenantColumn: this.config.tenantColumn, confidence: 0, recommendations: [], errors: [error.message], warnings: [], metadata: { totalTables: 0, tenantScopedCount: 0, sharedCount: 0, averageConfidence: 0, detectionMethod: 'column_analysis' } }; } } /** * Analyze a specific table for tenant scope indicators */ async analyzeTenantScope(tableName, schema) { try { // Get column information const { data: columns, error } = await this.client .from('information_schema.columns') .select('column_name, data_type, is_nullable') .eq('table_name', tableName) .eq('table_schema', schema); if (error || !columns) { return this.createDefaultScopeInfo(tableName, schema, false, 0); } const columnNames = columns.map(col => col.column_name); // Check for tenant columns let tenantColumn = ''; let confidence = 0; for (const candidate of tenant_types_1.COMMON_TENANT_COLUMNS) { if (columnNames.includes(candidate)) { tenantColumn = candidate; confidence += 0.4; break; } } // Check for RLS policies const hasRLS = await this.checkRLSPolicies(tableName, schema); if (hasRLS) { confidence += 0.3; } // Check for foreign key relationships to accounts/tenants const tenantForeignKeys = await this.getTenantForeignKeys(tableName, schema); if (tenantForeignKeys.length > 0) { confidence += 0.3; if (!tenantColumn) { tenantColumn = tenantForeignKeys[0].fromColumn; } } // Boost confidence for specific patterns (MakerKit) if (tableName === 'accounts' || tableName === 'profiles') { confidence += 0.2; } const isTenantScoped = confidence > 0.3 && tenantColumn !== ''; return { tableName, schema, tenantColumn, isTenantScoped, confidence: Math.min(confidence, 1.0), scopeType: confidence > 0.7 ? 'strict' : 'optional', metadata: { hasRLS, rlsPolicies: [], // Would populate in full implementation foreignKeys: tenantForeignKeys, constraints: [], triggers: [], isMultiTenant: isTenantScoped } }; } catch (error) { logger_1.Logger.warn(`Failed to analyze tenant scope for ${tableName}:`, error); return this.createDefaultScopeInfo(tableName, schema, false, 0); } } /** * Check if table has RLS policies */ async checkRLSPolicies(tableName, schema) { try { const { data: policies, error } = await this.client .from('pg_policies') .select('policyname') .eq('tablename', tableName) .eq('schemaname', schema); return !error && (policies?.length || 0) > 0; } catch { return false; } } /** * Get tenant-related foreign keys for a table */ async getTenantForeignKeys(tableName, schema) { try { const { data: constraints, error } = await this.client .from('information_schema.key_column_usage') .select(` constraint_name, column_name, referenced_table_name, referenced_column_name `) .eq('table_name', tableName) .eq('table_schema', schema) .in('referenced_table_name', ['accounts', 'tenants', 'organizations']); return constraints || []; } catch { return []; } } /** * Create tenant-scoped data with proper account_id assignment */ async createTenantScopedData(tableName, tenantId, data, options = {}) { logger_1.Logger.debug(`🏢 Creating tenant-scoped data for ${tableName} (tenant: ${tenantId})`); const scopeInfo = this.detectedTenantScopes.get(tableName); if (!scopeInfo || !scopeInfo.isTenantScoped) { logger_1.Logger.warn(`Table ${tableName} is not tenant-scoped, inserting without tenant assignment`); return data; } // Add tenant ID to all records const tenantScopedData = data.map(record => ({ ...record, [scopeInfo.tenantColumn]: tenantId, created_at: record.created_at || new Date().toISOString(), updated_at: record.updated_at || new Date().toISOString() })); // Validate tenant boundaries if enabled if (this.config.validationEnabled) { const validation = await this.validateTenantBoundaries(tableName, tenantScopedData); if (!validation.isValid) { logger_1.Logger.warn(`Tenant boundary validation failed for ${tableName}:`, validation.violations); if (this.config.strictIsolation) { throw new Error(`Strict tenant isolation violation in ${tableName}`); } } } return tenantScopedData; } /** * Validate tenant boundaries for data */ async validateTenantBoundaries(tableName, data) { const violations = []; const warnings = []; const recommendations = []; const scopeInfo = this.detectedTenantScopes.get(tableName); if (!scopeInfo) { return { isValid: true, violations, warnings: ['Table not analyzed for tenant scope'], recommendations, isolationScore: 0.5 }; } // Check for missing tenant IDs const missingTenantIds = data.filter(record => !record[scopeInfo.tenantColumn]); if (missingTenantIds.length > 0) { violations.push({ type: 'missing_tenant_id', severity: 'error', tableName, description: `${missingTenantIds.length} records missing ${scopeInfo.tenantColumn}`, suggestedFix: `Add ${scopeInfo.tenantColumn} to all records`, affectedFields: [scopeInfo.tenantColumn] }); } // Check for cross-tenant references const tenantIds = new Set(data.map(record => record[scopeInfo.tenantColumn]).filter(Boolean)); if (tenantIds.size > 1 && !this.config.dataGenerationOptions.allowCrossTenantRelationships) { violations.push({ type: 'cross_tenant_reference', severity: 'warning', tableName, description: `Data spans multiple tenants: ${Array.from(tenantIds).join(', ')}`, suggestedFix: 'Ensure data belongs to single tenant per operation', affectedFields: [scopeInfo.tenantColumn] }); } // Calculate isolation score const recordsWithTenantId = data.length - missingTenantIds.length; const isolationScore = data.length > 0 ? recordsWithTenantId / data.length : 1.0; const isValid = violations.filter(v => v.severity === 'error').length === 0; return { isValid, violations, warnings, recommendations, isolationScore }; } /** * Generate tenant accounts (personal and team) */ async generateTenantAccounts(count, options = {}) { logger_1.Logger.info(`🏢 Generating ${count} tenant accounts...`); const opts = { ...tenant_types_1.DEFAULT_TENANT_GENERATION_OPTIONS, ...options }; const tenants = []; const personalAccountCount = Math.floor(count * opts.personalAccountRatio); const teamAccountCount = count - personalAccountCount; // Generate personal accounts for (let i = 0; i < personalAccountCount; i++) { const tenant = await this.generatePersonalAccount(i); tenants.push(tenant); this.tenantCache.set(tenant.id, tenant); } // Generate team accounts for (let i = 0; i < teamAccountCount; i++) { const tenant = await this.generateTeamAccount(i); tenants.push(tenant); this.tenantCache.set(tenant.id, tenant); } logger_1.Logger.success(`✅ Generated ${tenants.length} tenant accounts (${personalAccountCount} personal, ${teamAccountCount} team)`); return tenants; } /** * Generate a personal account */ async generatePersonalAccount(index) { const id = `tenant_personal_${index + 1}`; const email = `user${index + 1}@supaseed.test`; return { id, type: 'personal', isPersonalAccount: true, slug: null, // Personal accounts have null slug in MakerKit name: `Personal Account ${index + 1}`, email, createdAt: new Date().toISOString(), metadata: { memberCount: 1, plan: 'free', features: ['basic_features'], settings: { theme: 'light', notifications: true }, limits: { maxUsers: 1, maxProjects: 3, maxStorage: 1024 * 1024 * 100, // 100MB maxApiCalls: 1000 } } }; } /** * Generate a team account */ async generateTeamAccount(index) { const id = `tenant_team_${index + 1}`; const slug = `team-${index + 1}`; return { id, type: 'team', isPersonalAccount: false, slug, name: `Team Account ${index + 1}`, createdAt: new Date().toISOString(), metadata: { memberCount: Math.floor(Math.random() * 8) + 2, // 2-10 members plan: 'pro', features: ['team_features', 'collaboration', 'advanced_analytics'], settings: { theme: 'light', notifications: true, teamFeatures: true }, limits: { maxUsers: 10, maxProjects: 20, maxStorage: 1024 * 1024 * 1024, // 1GB maxApiCalls: 10000 } } }; } /** * Create tenant isolation report */ async createTenantIsolationReport(tenantId) { logger_1.Logger.info(`📊 Creating tenant isolation report for: ${tenantId}`); const tenant = this.tenantCache.get(tenantId); if (!tenant) { throw new Error(`Tenant ${tenantId} not found in cache`); } const violations = []; const tableBreakdown = {}; let totalRecords = 0; let ownedRecords = 0; // Analyze each tenant-scoped table for (const [tableName, scopeInfo] of this.detectedTenantScopes) { try { const { data: records } = await this.client .from(tableName) .select('*') .eq(scopeInfo.tenantColumn, tenantId); const recordCount = records?.length || 0; totalRecords += recordCount; ownedRecords += recordCount; tableBreakdown[tableName] = { totalRecords: recordCount, ownedRecords: recordCount, violations: 0 }; } catch (error) { logger_1.Logger.warn(`Failed to analyze ${tableName} for tenant ${tenantId}:`, error); } } const isolationScore = totalRecords > 0 ? ownedRecords / totalRecords : 1.0; return { tenantId, tenantType: tenant.type, isolationScore, violations, recommendations: this.generateIsolationRecommendations(isolationScore, violations), dataBreakdown: { totalRecords, ownedRecords, accessibleRecords: ownedRecords, // Simplified crossTenantRecords: 0 }, tableBreakdown }; } /** * Helper methods */ createDefaultScopeInfo(tableName, schema, isTenantScoped, confidence) { return { tableName, schema, tenantColumn: this.config.tenantColumn, isTenantScoped, confidence, scopeType: 'optional', metadata: { hasRLS: false, rlsPolicies: [], foreignKeys: [], constraints: [], triggers: [], isMultiTenant: isTenantScoped } }; } generateTenantRecommendations(tenantScoped, shared) { const recommendations = []; if (tenantScoped.length > 0) { recommendations.push(`Detected ${tenantScoped.length} tenant-scoped tables - enable multi-tenant mode`); recommendations.push('Ensure all tenant-scoped data includes proper tenant identifiers'); } if (shared.length > 0) { recommendations.push(`${shared.length} shared tables detected - consider if they need tenant scoping`); } const lowConfidenceTables = tenantScoped.filter(t => t.confidence < 0.7); if (lowConfidenceTables.length > 0) { recommendations.push(`${lowConfidenceTables.length} tables have low tenant confidence - verify manually`); } return recommendations; } generateIsolationRecommendations(isolationScore, violations) { const recommendations = []; if (isolationScore < 0.8) { recommendations.push('Tenant isolation score is low - review data access patterns'); } if (violations.length > 0) { recommendations.push(`${violations.length} tenant violations found - address before production`); } recommendations.push('Regularly audit tenant data isolation for security compliance'); return recommendations; } /** * Public getters */ getTenantScopeInfo(tableName) { return this.detectedTenantScopes.get(tableName); } getAllTenantScopes() { return Array.from(this.detectedTenantScopes.values()); } getTenantInfo(tenantId) { return this.tenantCache.get(tenantId); } getAllTenants() { return Array.from(this.tenantCache.values()); } getConfig() { return { ...this.config }; } updateConfig(updates) { this.config = { ...this.config, ...updates }; } } exports.MultiTenantManager = MultiTenantManager; //# sourceMappingURL=multi-tenant-manager.js.map