From e4885db8283cadcfb5252af547f7e3d39b225c93 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Sat, 26 Jul 2025 18:46:52 +0200 Subject: [PATCH] REFACTOR API gateway --- frontend/src/api/services/dataService.ts | 163 ++++---- frontend/src/api/services/tenantService.ts | 299 +-------------- frontend/src/api/services/trainingService.ts | 54 +-- frontend/src/pages/onboarding.tsx | 4 +- gateway/app/main.py | 7 +- gateway/app/middleware/auth.py | 240 +++++------- gateway/app/routes/data.py | 89 ----- gateway/app/routes/forecasting.py | 74 ---- gateway/app/routes/tenant.py | 278 ++++++++------ gateway/app/routes/training.py | 100 ----- services/auth/app/api/auth.py | 2 +- services/auth/app/api/users.py | 44 +-- services/data/app/api/sales.py | 186 ++++++--- services/data/app/api/traffic.py | 16 +- services/data/app/api/weather.py | 21 +- services/data/app/main.py | 6 +- services/tenant/app/api/tenants.py | 13 +- services/tenant/app/main.py | 2 +- services/training/app/api/training.py | 30 +- services/training/app/main.py | 14 +- .../training/app/services/training_service.py | 4 +- shared/auth/decorators.py | 10 +- shared/auth/tenant_access.py | 356 ++++++++++++++++++ test_new.sh | 117 ++++++ 24 files changed, 1049 insertions(+), 1080 deletions(-) delete mode 100644 gateway/app/routes/data.py delete mode 100644 gateway/app/routes/forecasting.py delete mode 100644 gateway/app/routes/training.py create mode 100644 shared/auth/tenant_access.py create mode 100755 test_new.sh diff --git a/frontend/src/api/services/dataService.ts b/frontend/src/api/services/dataService.ts index d65fb6bf..cb0b4aa1 100644 --- a/frontend/src/api/services/dataService.ts +++ b/frontend/src/api/services/dataService.ts @@ -99,20 +99,20 @@ export class DataService { */ async uploadSalesHistory( file: File, - tenantId?: string, + tenantId: string, // Tenant ID is now a required path parameter additionalData?: Record ): Promise { try { console.log('Uploading sales file:', file.name); - + // ✅ CRITICAL FIX: Use the correct endpoint that exists in backend // Backend endpoint: @router.post("/import", response_model=SalesImportResult) - // Full path: /api/v1/data/sales/import (mounted with prefix /api/v1/sales) - + // Full path: /api/v1/tenants/{tenant_id}/sales/import + // Determine file format const fileName = file.name.toLowerCase(); let fileFormat: string; - + if (fileName.endsWith('.csv')) { fileFormat = 'csv'; } else if (fileName.endsWith('.json')) { @@ -122,28 +122,29 @@ export class DataService { } else { fileFormat = 'csv'; // Default fallback } - + // ✅ FIXED: Create FormData manually to match backend expectations const formData = new FormData(); formData.append('file', file); formData.append('file_format', fileFormat); - if (tenantId) { - formData.append('tenant_id', tenantId); - } - + // tenantId is no longer appended to FormData as it's a path parameter + // if (tenantId) { + // formData.append('tenant_id', tenantId); + // } + // Add additional data if provided if (additionalData) { Object.entries(additionalData).forEach(([key, value]) => { formData.append(key, String(value)); }); } - + console.log('Uploading with file_format:', fileFormat); - + // ✅ FIXED: Use the correct endpoint that exists in the backend const response = await apiClient.request>( - '/api/v1/data/sales/import', // Correct endpoint path + `/api/v1/tenants/${tenantId}/sales/import`, // Correct endpoint path with tenant_id { method: 'POST', body: formData, @@ -151,16 +152,16 @@ export class DataService { headers: {} // Empty headers to avoid setting Content-Type manually } ); - + console.log('Upload response:', response); - + // ✅ Handle the SalesImportResult response structure if (response && typeof response === 'object') { // Handle API errors if ('detail' in response) { throw new Error(typeof response.detail === 'string' ? response.detail : 'Upload failed'); } - + // Extract data from response let uploadResult: any; if ('data' in response && response.data) { @@ -168,28 +169,28 @@ export class DataService { } else { uploadResult = response; } - + // ✅ FIXED: Map backend SalesImportResult to frontend UploadResponse return { - message: uploadResult.success + message: uploadResult.success ? `Successfully processed ${uploadResult.records_created || uploadResult.records_processed || 0} records` : 'Upload completed with issues', records_processed: uploadResult.records_created || uploadResult.records_processed || 0, - errors: uploadResult.errors ? - (Array.isArray(uploadResult.errors) ? - uploadResult.errors.map((err: any) => + errors: uploadResult.errors ? + (Array.isArray(uploadResult.errors) ? + uploadResult.errors.map((err: any) => typeof err === 'string' ? err : (err.message || String(err)) ) : [String(uploadResult.errors)] ) : [], upload_id: uploadResult.id || undefined }; } - + throw new Error('Invalid response format from upload service'); - + } catch (error: any) { console.error('Error uploading file:', error); - + let errorMessage = 'Error al subir el archivo'; if (error.response?.status === 422) { errorMessage = 'Formato de archivo inválido'; @@ -200,7 +201,7 @@ export class DataService { } else if (error.message) { errorMessage = error.message; } - + // Throw structured error that can be caught by the frontend throw { message: errorMessage, @@ -216,20 +217,20 @@ export class DataService { * ✅ ALTERNATIVE: Upload sales data using the JSON import endpoint * This uses the same endpoint as validation but with validate_only: false */ - async uploadSalesDataAsJson(file: File, tenantId?: string): Promise { + async uploadSalesDataAsJson(file: File, tenantId: string): Promise { // tenantId made required try { console.log('Uploading sales data as JSON:', file.name); - + const fileContent = await this.readFileAsText(file); - + if (!fileContent) { throw new Error('Failed to read file content'); } - + // Determine file format const fileName = file.name.toLowerCase(); let dataFormat: 'csv' | 'json' | 'excel'; - + if (fileName.endsWith('.csv')) { dataFormat = 'csv'; } else if (fileName.endsWith('.json')) { @@ -239,59 +240,67 @@ export class DataService { } else { dataFormat = 'csv'; } - + // ✅ Use the same structure as validation but with validate_only: false const importData: SalesDataImportRequest = { - tenant_id: tenantId || '00000000-0000-0000-0000-000000000000', + tenant_id: tenantId, // Use the provided tenantId data: fileContent, data_format: dataFormat, validate_only: false, // This makes it actually import the data source: 'onboarding_upload' }; - + console.log('Uploading data with validate_only: false'); - + // ✅ OPTION: Add a new JSON import endpoint to the backend + // Current backend sales.py does not have a /import/json endpoint, + // it only has a file upload endpoint. + // If a JSON import endpoint is desired, it needs to be added to sales.py + // For now, this method will target the existing /import endpoint with a JSON body + // This will require the backend to support JSON body for /import, which it currently + // does not for the direct file upload endpoint. + // THIS ALTERNATIVE METHOD IS LEFT AS-IS, ASSUMING A FUTURE BACKEND ENDPOINT + // OR A MODIFICATION TO THE EXISTING /import ENDPOINT TO ACCEPT JSON BODY. const response = await apiClient.post>( - '/api/v1/data/sales/import/json', // Need to add this endpoint to backend + `/api/v1/tenants/${tenantId}/sales/import/json`, // This endpoint does not exist in sales.py importData ); - + console.log('JSON upload response:', response); - + // Handle response similar to file upload if (response && typeof response === 'object') { if ('detail' in response) { throw new Error(typeof response.detail === 'string' ? response.detail : 'Upload failed'); } - + let uploadResult: any; if ('data' in response && response.data) { uploadResult = response.data; } else { uploadResult = response; } - + return { - message: uploadResult.success + message: uploadResult.success ? `Successfully processed ${uploadResult.records_created || uploadResult.records_processed || 0} records` : 'Upload completed with issues', records_processed: uploadResult.records_created || uploadResult.records_processed || 0, - errors: uploadResult.errors ? - (Array.isArray(uploadResult.errors) ? - uploadResult.errors.map((err: any) => + errors: uploadResult.errors ? + (Array.isArray(uploadResult.errors) ? + uploadResult.errors.map((err: any) => typeof err === 'string' ? err : (err.message || String(err)) ) : [String(uploadResult.errors)] ) : [], upload_id: uploadResult.id || undefined }; } - + throw new Error('Invalid response format from upload service'); - + } catch (error: any) { console.error('Error uploading JSON data:', error); - + let errorMessage = 'Error al subir los datos'; if (error.response?.status === 422) { errorMessage = 'Formato de datos inválido'; @@ -302,7 +311,7 @@ export class DataService { } else if (error.message) { errorMessage = error.message; } - + throw { message: errorMessage, status: error.response?.status || 0, @@ -312,22 +321,22 @@ export class DataService { } } - async validateSalesData(file: File, tenantId?: string): Promise { + async validateSalesData(file: File, tenantId: string): Promise { // tenantId made required try { console.log('Reading file content...', file.name); - + const fileContent = await this.readFileAsText(file); - + if (!fileContent) { throw new Error('Failed to read file content'); } console.log('File content read successfully, length:', fileContent.length); - + // Determine file format from extension const fileName = file.name.toLowerCase(); let dataFormat: 'csv' | 'json' | 'excel'; - + if (fileName.endsWith('.csv')) { dataFormat = 'csv'; } else if (fileName.endsWith('.json')) { @@ -342,7 +351,7 @@ export class DataService { // ✅ FIXED: Use proper tenant ID when available const importData: SalesDataImportRequest = { - tenant_id: tenantId || '00000000-0000-0000-0000-000000000000', + tenant_id: tenantId, // Use the provided tenantId data: fileContent, data_format: dataFormat, validate_only: true @@ -351,10 +360,10 @@ export class DataService { console.log('Sending validation request with tenant_id:', importData.tenant_id); const response = await apiClient.post>( - '/api/v1/data/sales/import/validate', + `/api/v1/tenants/${tenantId}/sales/import/validate`, // Correct endpoint with tenant_id importData ); - + console.log('Raw response from API:', response); // ✅ ENHANCED: Handle the new backend response structure @@ -362,7 +371,7 @@ export class DataService { // Handle API errors if ('detail' in response) { console.error('API returned error:', response.detail); - + if (Array.isArray(response.detail)) { // Handle Pydantic validation errors const errorMessages = response.detail.map(err => ({ @@ -371,7 +380,7 @@ export class DataService { field: err.loc ? err.loc[err.loc.length - 1] : null, code: err.type })); - + return { is_valid: false, total_records: 0, @@ -385,7 +394,7 @@ export class DataService { } }; } - + // Handle simple error messages return { is_valid: false, @@ -407,7 +416,7 @@ export class DataService { // ✅ SUCCESS: Handle successful validation response let validationResult: DataValidation; - + // Check if response has nested data if ('data' in response && response.data) { validationResult = response.data; @@ -427,7 +436,7 @@ export class DataService { errors: validationResult.errors || [], warnings: validationResult.warnings || [], summary: validationResult.summary || { status: 'unknown', suggestions: [] }, - + // Backward compatibility fields valid: validationResult.is_valid, // Map for legacy code recordCount: validationResult.total_records, @@ -436,13 +445,13 @@ export class DataService { } throw new Error('Invalid response format from validation service'); - + } catch (error: any) { console.error('Error validating file:', error); - + let errorMessage = 'Error al validar el archivo'; let errorCode = 'UNKNOWN_ERROR'; - + if (error.response?.status === 422) { errorMessage = 'Formato de archivo inválido'; errorCode = 'INVALID_FORMAT'; @@ -456,7 +465,7 @@ export class DataService { errorMessage = error.message; errorCode = 'CLIENT_ERROR'; } - + // Return properly structured error response matching new schema return { is_valid: false, @@ -473,7 +482,7 @@ export class DataService { status: 'error', suggestions: ['Intenta con un archivo diferente o contacta soporte'] }, - + // Backward compatibility valid: false, recordCount: 0, @@ -488,7 +497,7 @@ export class DataService { private readFileAsText(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); - + reader.onload = (event) => { const result = event.target?.result; if (typeof result === 'string') { @@ -497,15 +506,15 @@ export class DataService { reject(new Error('Failed to read file as text')); } }; - + reader.onerror = () => { reject(new Error('Failed to read file')); }; - + reader.onabort = () => { reject(new Error('File reading was aborted')); }; - + // Read the file as text reader.readAsText(file); }); @@ -524,7 +533,7 @@ export class DataService { /** * Get sales records */ - async getSalesRecords(params?: { + async getSalesRecords(tenantId: string, params?: { // Add tenantId startDate?: string; endDate?: string; productName?: string; @@ -536,16 +545,16 @@ export class DataService { total: number; page: number; pages: number; - }>>('/api/v1/data/sales', { params }); + }>>(`/api/v1/tenants/${tenantId}/sales`, { params }); // Use tenantId in path return response.data!; } /** * Create single sales record */ - async createSalesRecord(record: CreateSalesRequest): Promise { + async createSalesRecord(tenantId: string, record: CreateSalesRequest): Promise { // Add tenantId const response = await apiClient.post>( - '/api/v1/data/sales', + `/api/v1/tenants/${tenantId}/sales`, // Use tenantId in path record ); return response.data!; @@ -554,9 +563,9 @@ export class DataService { /** * Update sales record */ - async updateSalesRecord(id: string, record: Partial): Promise { + async updateSalesRecord(tenantId: string, id: string, record: Partial): Promise { // Add tenantId const response = await apiClient.put>( - `/api/v1/data/sales/${id}`, + `/api/v1/tenants/${tenantId}/sales/${id}`, // Use tenantId in path record ); return response.data!; @@ -565,8 +574,8 @@ export class DataService { /** * Delete sales record */ - async deleteSalesRecord(id: string): Promise { - await apiClient.delete(`/api/v1/data/sales/${id}`); + async deleteSalesRecord(tenantId: string, id: string): Promise { // Add tenantId + await apiClient.delete(`/api/v1/tenants/${tenantId}/sales/${id}`); // Use tenantId in path } /** diff --git a/frontend/src/api/services/tenantService.ts b/frontend/src/api/services/tenantService.ts index 5a93ff68..a292729c 100644 --- a/frontend/src/api/services/tenantService.ts +++ b/frontend/src/api/services/tenantService.ts @@ -142,303 +142,8 @@ export class TenantService { * Corresponds to GET /users/{user_id}/tenants */ async getUserTenants(userId: string): Promise { - const response = await apiClient.get>(`/api/v1/users/${userId}/tenants`); + const response = await apiClient.get>(`/api/v1/tenants/user/${userId}`); return response.data!; } - /** - * Add a team member to a tenant - * Corresponds to POST /tenants/{tenant_id}/members - */ - async addTeamMember(tenantId: string, userId: string, role: string): Promise { - const response = await apiClient.post>( - `/api/v1/tenants/${tenantId}/members`, - { user_id: userId, role } - ); - return response.data!; - } - - // --- Existing methods (kept for completeness, assuming they map to other backend endpoints not provided) --- - - /** - * Get current tenant info (no direct backend mapping in provided file, but common) - */ - async getCurrentTenant(): Promise { - const response = await apiClient.get>('/api/v1/tenants/current'); - return response.data!; - } - - /** - * Update current tenant (no direct backend mapping in provided file, but common) - */ - async updateCurrentTenant(updates: TenantUpdate): Promise { - const response = await apiClient.put>('/api/v1/tenants/current', updates); - return response.data!; - } - - /** - * Get tenant settings (no direct backend mapping in provided file) - */ - async getTenantSettings(): Promise { - const response = await apiClient.get>('/api/v1/tenants/settings'); - return response.data!; - } - - /** - * Update tenant settings (no direct backend mapping in provided file) - */ - async updateTenantSettings(settings: Partial): Promise { - const response = await apiClient.put>( - '/api/v1/tenants/settings', - settings - ); - return response.data!; - } - - /** - * Get tenant statistics (no direct backend mapping in provided file) - */ - async getTenantStats(): Promise { - const response = await apiClient.get>('/api/v1/tenants/stats'); - return response.data!; - } - - /** - * Get tenant users (no direct backend mapping in provided file) - */ - async getTenantUsers(params?: { - role?: string; - active?: boolean; - page?: number; - limit?: number; - }): Promise<{ - users: TenantUser[]; - total: number; - page: number; - pages: number; - }> { - const response = await apiClient.get>('/api/v1/tenants/users', { params }); - return response.data!; - } - - /** - * Invite user to tenant (no direct backend mapping in provided file) - */ - async inviteUser(invitation: InviteUser): Promise<{ - invitation_id: string; - email: string; - role: string; - expires_at: string; - invitation_token: string; - }> { - const response = await apiClient.post>('/api/v1/tenants/users/invite', invitation); - return response.data!; - } - - /** - * Update user role (no direct backend mapping in provided file) - */ - async updateUserRole(userId: string, role: string): Promise { - const response = await apiClient.patch>( - `/api/v1/tenants/users/${userId}`, - { role } - ); - return response.data!; - } - - /** - * Deactivate user (no direct backend mapping in provided file) - */ - async deactivateUser(userId: string): Promise { - const response = await apiClient.patch>( - `/api/v1/tenants/users/${userId}`, - { is_active: false } - ); - return response.data!; - } - - /** - * Reactivate user (no direct backend mapping in provided file) - */ - async reactivateUser(userId: string): Promise { - const response = await apiClient.patch>( - `/api/v1/tenants/users/${userId}`, - { is_active: true } - ); - return response.data!; - } - - /** - * Remove user from tenant (no direct backend mapping in provided file) - */ - async removeUser(userId: string): Promise { - await apiClient.delete(`/api/v1/tenants/users/${userId}`); - } - - /** - * Get pending invitations (no direct backend mapping in provided file) - */ - async getPendingInvitations(): Promise<{ - id: string; - email: string; - role: string; - invited_at: string; - expires_at: string; - invited_by: string; - }[]> { - const response = await apiClient.get>('/api/v1/tenants/invitations'); - return response.data!; - } - - /** - * Cancel invitation (no direct backend mapping in provided file) - */ - async cancelInvitation(invitationId: string): Promise { - await apiClient.delete(`/api/v1/tenants/invitations/${invitationId}`); - } - - /** - * Resend invitation (no direct backend mapping in provided file) - */ - async resendInvitation(invitationId: string): Promise { - await apiClient.post(`/api/v1/tenants/invitations/${invitationId}/resend`); - } - - /** - * Get tenant activity log (no direct backend mapping in provided file) - */ - async getActivityLog(params?: { - userId?: string; - action?: string; - startDate?: string; - endDate?: string; - page?: number; - limit?: number; - }): Promise<{ - activities: { - id: string; - user_id: string; - user_name: string; - action: string; - resource: string; - resource_id: string; - details?: Record; - ip_address?: string; - user_agent?: string; - created_at: string; - }[]; - total: number; - page: number; - pages: number; - }> { - const response = await apiClient.get>('/api/v1/tenants/activity', { params }); - return response.data!; - } - - /** - * Get tenant billing info (no direct backend mapping in provided file) - */ - async getBillingInfo(): Promise<{ - subscription_plan: string; - billing_cycle: 'monthly' | 'yearly'; - next_billing_date: string; - amount: number; - currency: string; - payment_method: { - type: string; - last_four: string; - expires: string; - }; - usage: { - api_calls: number; - storage_mb: number; - users: number; - limits: { - api_calls_per_month: number; - storage_mb: number; - max_users: number; - }; - }; - }> { - const response = await apiClient.get>('/api/v1/tenants/billing'); - return response.data!; - } - - /** - * Update billing info (no direct backend mapping in provided file) - */ - async updateBillingInfo(billingData: { - payment_method_token?: string; - billing_address?: { - street: string; - city: string; - state: string; - zip: string; - country: string; - }; - }): Promise { - await apiClient.put('/api/v1/tenants/billing', billingData); - } - - /** - * Change subscription plan (no direct backend mapping in provided file) - */ - async changeSubscriptionPlan( - planId: string, - billingCycle: 'monthly' | 'yearly' - ): Promise<{ - subscription_id: string; - plan: string; - billing_cycle: string; - next_billing_date: string; - proration_amount?: number; - }> { - const response = await apiClient.post>('/api/v1/tenants/subscription/change', { - plan_id: planId, - billing_cycle: billingCycle, - }); - return response.data!; - } - - /** - * Cancel subscription (no direct backend mapping in provided file) - */ - async cancelSubscription(cancelAt: 'immediately' | 'end_of_period'): Promise<{ - cancelled_at: string; - will_cancel_at: string; - refund_amount?: number; - }> { - const response = await apiClient.post>('/api/v1/tenants/subscription/cancel', { - cancel_at: cancelAt, - }); - return response.data!; - } - - /** - * Export tenant data (no direct backend mapping in provided file) - */ - async exportTenantData(dataTypes: string[], format: 'json' | 'csv'): Promise { - const response = await apiClient.post('/api/v1/tenants/export', { - data_types: dataTypes, - format, - responseType: 'blob', - }); - return response as unknown as Blob; - } - - /** - * Delete tenant (GDPR compliance) (no direct backend mapping in provided file) - */ - async deleteTenant(confirmationToken: string): Promise<{ - deletion_scheduled_at: string; - data_retention_until: string; - recovery_period_days: number; - }> { - const response = await apiClient.delete>('/api/v1/tenants/current', { - data: { confirmation_token: confirmationToken }, - }); - return response.data!; - } -} - -export const tenantService = new TenantService(); \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/src/api/services/trainingService.ts b/frontend/src/api/services/trainingService.ts index 592d3444..f541ff36 100644 --- a/frontend/src/api/services/trainingService.ts +++ b/frontend/src/api/services/trainingService.ts @@ -75,9 +75,9 @@ export class TrainingService { /** * Start new training job */ - async startTraining(config: TrainingConfiguration): Promise { + async startTraining(tenantId: string, config: TrainingConfiguration): Promise { const response = await apiClient.post( - '/api/v1/training/jobs', + `/api/v1/tenants/${tenantId}/training/jobs`, config ); return response.data!; @@ -86,9 +86,9 @@ export class TrainingService { /** * Get training job status */ - async getTrainingStatus(jobId: string): Promise { + async getTrainingStatus(tenantId: string, jobId: string): Promise { const response = await apiClient.get>( - `/api/v1/training/jobs/${jobId}` + `/api/v1/tenants/${tenantId}/training/jobs/${jobId}` ); return response.data!; } @@ -96,7 +96,7 @@ export class TrainingService { /** * Get all training jobs */ - async getTrainingHistory(params?: { + async getTrainingHistory(tenantId: string, params?: { page?: number; limit?: number; status?: string; @@ -113,14 +113,14 @@ export class TrainingService { /** * Cancel training job */ - async cancelTraining(jobId: string): Promise { - await apiClient.post(`/api/v1/training/jobs/${jobId}/cancel`); + async cancelTraining(tenantId: string, jobId: string): Promise { + await apiClient.post(`/api/v1/tenants/${tenantId}/training/jobs/${jobId}/cancel`); } /** * Get trained models */ - async getModels(params?: { + async getModels(tenantId: string, params?: { productName?: string; active?: boolean; page?: number; @@ -131,14 +131,14 @@ export class TrainingService { page: number; pages: number; }> { - const response = await apiClient.get>('/api/v1/training/models', { params }); + const response = await apiClient.get>(`/api/v1/tenants/${tenantId}/training/models`, { params }); return response.data!; } /** * Get specific model details */ - async getModel(modelId: string): Promise { + async getModel(tenantId: string, modelId: string): Promise { const response = await apiClient.get>( `/api/v1/training/models/${modelId}` ); @@ -148,9 +148,9 @@ export class TrainingService { /** * Get model metrics */ - async getModelMetrics(modelId: string): Promise { + async getModelMetrics(tenantId: string, modelId: string): Promise { const response = await apiClient.get>( - `/api/v1/training/models/${modelId}/metrics` + `/api/v1/tenants/${tenantId}/training/models/${modelId}/metrics` ); return response.data!; } @@ -158,9 +158,9 @@ export class TrainingService { /** * Activate/deactivate model */ - async toggleModelStatus(modelId: string, active: boolean): Promise { + async toggleModelStatus(tenantId: string, modelId: string, active: boolean): Promise { const response = await apiClient.patch>( - `/api/v1/training/models/${modelId}`, + `/api/v1/tenants/${tenantId}/training/models/${modelId}`, { is_active: active } ); return response.data!; @@ -169,16 +169,16 @@ export class TrainingService { /** * Delete model */ - async deleteModel(modelId: string): Promise { + async deleteModel(tenantId: string, modelId: string): Promise { await apiClient.delete(`/api/v1/training/models/${modelId}`); } /** * Train specific product */ - async trainProduct(productName: string, config?: Partial): Promise { + async trainProduct(tenantId: string, productName: string, config?: Partial): Promise { const response = await apiClient.post>( - '/api/v1/training/products/train', + `/api/v1/tenants/${tenantId}/training/products/train`, { product_name: productName, ...config, @@ -190,7 +190,7 @@ export class TrainingService { /** * Get training statistics */ - async getTrainingStats(): Promise<{ + async getTrainingStats(tenantId: string): Promise<{ total_models: number; active_models: number; avg_accuracy: number; @@ -198,21 +198,21 @@ export class TrainingService { products_trained: number; training_time_avg_minutes: number; }> { - const response = await apiClient.get>('/api/v1/training/stats'); + const response = await apiClient.get>(`/api/v1/tenants/${tenantId}/training/stats`); return response.data!; } /** * Validate training data */ - async validateTrainingData(products?: string[]): Promise<{ + async validateTrainingData(tenantId: string, products?: string[]): Promise<{ valid: boolean; errors: string[]; warnings: string[]; product_data_points: Record; recommendation: string; }> { - const response = await apiClient.post>('/api/v1/training/validate', { + const response = await apiClient.post>(`/api/v1/tenants/${tenantId}/training/validate`, { products, }); return response.data!; @@ -221,29 +221,29 @@ export class TrainingService { /** * Get training recommendations */ - async getTrainingRecommendations(): Promise<{ + async getTrainingRecommendations(tenantId: string): Promise<{ should_retrain: boolean; reasons: string[]; recommended_products: string[]; optimal_config: TrainingConfiguration; }> { - const response = await apiClient.get>('/api/v1/training/recommendations'); + const response = await apiClient.get>(`/api/v1/tenants/${tenantId}/training/recommendations`); return response.data!; } /** * Get training logs */ - async getTrainingLogs(jobId: string): Promise { - const response = await apiClient.get>(`/api/v1/training/jobs/${jobId}/logs`); + async getTrainingLogs(tenantId: string, jobId: string): Promise { + const response = await apiClient.get>(`/api/v1/tenants/${tenantId}/training/jobs/${jobId}/logs`); return response.data!; } /** * Export model */ - async exportModel(modelId: string, format: 'pickle' | 'onnx' = 'pickle'): Promise { - const response = await apiClient.get(`/api/v1/training/models/${modelId}/export`, { + async exportModel(tenantId: string, modelId: string, format: 'pickle' | 'onnx' = 'pickle'): Promise { + const response = await apiClient.get(`/api/v1/tenants/${tenantId}/training/models/${modelId}/export`, { params: { format }, responseType: 'blob', }); diff --git a/frontend/src/pages/onboarding.tsx b/frontend/src/pages/onboarding.tsx index 86592c89..66c02055 100644 --- a/frontend/src/pages/onboarding.tsx +++ b/frontend/src/pages/onboarding.tsx @@ -137,7 +137,7 @@ const OnboardingPage = () => { console.log('Starting training with config:', trainingConfig); // Start training via API - const trainingJob: TrainingJobStatus = await api.training.startTraining(trainingConfig); + const trainingJob: TrainingJobStatus = await api.training.startTraining(currentTenantId, trainingConfig); // Update form data with training job ID setFormData(prev => ({ @@ -326,7 +326,7 @@ const OnboardingPage = () => { hyperparameter_tuning: true }; - const trainingJob = await api.training.startTraining(trainingConfig); + const trainingJob = await api.training.startTraining(currentTenantId, trainingConfig); setFormData(prev => ({ ...prev, trainingTaskId: trainingJob.id, diff --git a/gateway/app/main.py b/gateway/app/main.py index 2d8bf460..0047af4a 100644 --- a/gateway/app/main.py +++ b/gateway/app/main.py @@ -17,7 +17,7 @@ from app.core.service_discovery import ServiceDiscovery from app.middleware.auth import AuthMiddleware from app.middleware.logging import LoggingMiddleware from app.middleware.rate_limit import RateLimitMiddleware -from app.routes import auth, training, forecasting, data, tenant, notification, nominatim, user +from app.routes import auth, tenant, notification, nominatim, user from shared.monitoring.logging import setup_logging from shared.monitoring.metrics import MetricsCollector @@ -56,10 +56,7 @@ app.add_middleware(AuthMiddleware) # Include routers app.include_router(auth.router, prefix="/api/v1/auth", tags=["authentication"]) -app.include_router(auth.router, prefix="/api/v1/user", tags=["user"]) -app.include_router(training.router, prefix="/api/v1/training", tags=["training"]) -app.include_router(forecasting.router, prefix="/api/v1/forecasting", tags=["forecasting"]) -app.include_router(data.router, prefix="/api/v1/data", tags=["data"]) +app.include_router(user.router, prefix="/api/v1/user", tags=["user"]) app.include_router(tenant.router, prefix="/api/v1/tenants", tags=["tenants"]) app.include_router(notification.router, prefix="/api/v1/notifications", tags=["notifications"]) app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"]) diff --git a/gateway/app/middleware/auth.py b/gateway/app/middleware/auth.py index 255ce08f..a018a40b 100644 --- a/gateway/app/middleware/auth.py +++ b/gateway/app/middleware/auth.py @@ -1,7 +1,6 @@ -# gateway/app/middleware/auth.py - IMPROVED VERSION +# gateway/app/middleware/auth.py """ -Enhanced Authentication Middleware for API Gateway -Implements proper token validation and tenant context extraction +Enhanced Authentication Middleware for API Gateway with Tenant Access Control """ import structlog @@ -9,12 +8,11 @@ from fastapi import Request, HTTPException from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import Response -import httpx from typing import Optional, Dict, Any -import asyncio from app.core.config import settings from shared.auth.jwt_handler import JWTHandler +from shared.auth.tenant_access import tenant_access_manager, extract_tenant_id_from_path, is_tenant_scoped_path logger = structlog.get_logger() @@ -32,20 +30,12 @@ PUBLIC_ROUTES = [ "/api/v1/auth/register", "/api/v1/auth/refresh", "/api/v1/auth/verify", - "/api/v1/tenant/register", "/api/v1/nominatim/search" ] class AuthMiddleware(BaseHTTPMiddleware): """ - Enhanced Authentication Middleware following microservices best practices - - Responsibilities: - 1. Token validation (local first, then auth service) - 2. User context injection - 3. Tenant context extraction (per request) - 4. Rate limiting enforcement - 5. Request routing decisions + Enhanced Authentication Middleware with Tenant Access Control """ def __init__(self, app, redis_client=None): @@ -53,17 +43,17 @@ class AuthMiddleware(BaseHTTPMiddleware): self.redis_client = redis_client # For caching and rate limiting async def dispatch(self, request: Request, call_next) -> Response: - """Process request with enhanced authentication""" - + """Process request with enhanced authentication and tenant access control""" + # Skip authentication for OPTIONS requests (CORS preflight) if request.method == "OPTIONS": return await call_next(request) - + # Skip authentication for public routes if self._is_public_route(request.url.path): return await call_next(request) - - # Extract and validate JWT token + + # ✅ STEP 1: Extract and validate JWT token token = self._extract_token(request) if not token: logger.warning(f"Missing token for protected route: {request.url.path}") @@ -71,8 +61,9 @@ class AuthMiddleware(BaseHTTPMiddleware): status_code=401, content={"detail": "Authentication required"} ) - - # Verify token and get user context + + # ✅ STEP 2: Verify token and get user context + # Pass self.redis_client to _verify_token to enable caching user_context = await self._verify_token(token) if not user_context: logger.warning(f"Invalid token for route: {request.url.path}") @@ -80,30 +71,50 @@ class AuthMiddleware(BaseHTTPMiddleware): status_code=401, content={"detail": "Invalid or expired token"} ) - - # Extract tenant context from request (not from JWT) - tenant_id = self._extract_tenant_from_request(request) - - # Verify user has access to tenant (if tenant_id provided) - if tenant_id: - has_access = await self._verify_tenant_access(user_context["user_id"], tenant_id) + + # ✅ STEP 3: Extract tenant context from URL using shared utility + tenant_id = extract_tenant_id_from_path(request.url.path) + + # ✅ STEP 4: Verify tenant access if this is a tenant-scoped route + if tenant_id and is_tenant_scoped_path(request.url.path): + # Use TenantAccessManager for gateway-level verification with caching + # Ensure tenant_access_manager uses the redis_client from the middleware + if self.redis_client and tenant_access_manager.redis_client is None: + tenant_access_manager.redis_client = self.redis_client + + has_access = await tenant_access_manager.verify_basic_tenant_access( # Corrected method call + user_context["user_id"], + tenant_id + ) + if not has_access: logger.warning(f"User {user_context['email']} denied access to tenant {tenant_id}") return JSONResponse( status_code=403, - content={"detail": "Access denied to tenant"} + content={"detail": f"Access denied to tenant {tenant_id}"} ) + + # Set tenant context in request state request.state.tenant_id = tenant_id - - # Inject user context into request + request.state.tenant_verified = True + + logger.debug(f"Tenant access verified", + user_id=user_context["user_id"], + tenant_id=tenant_id, + path=request.url.path) + + # ✅ STEP 5: Inject user context into request request.state.user = user_context request.state.authenticated = True - - # Add user context to forwarded requests - self._inject_auth_headers(request, user_context, tenant_id) - - logger.debug(f"Authenticated request: {user_context['email']} -> {request.url.path}") - + + # ✅ STEP 6: Add context headers for downstream services + self._inject_context_headers(request, user_context, tenant_id) + + logger.debug(f"Authenticated request", + user_email=user_context['email'], + tenant_id=tenant_id, + path=request.url.path) + return await call_next(request) def _is_public_route(self, path: str) -> bool: @@ -117,46 +128,10 @@ class AuthMiddleware(BaseHTTPMiddleware): return auth_header.split(" ")[1] return None - def _extract_tenant_from_request(self, request: Request) -> Optional[str]: - """ - Extract tenant ID from request (NOT from JWT token) - - Priority order: - 1. X-Tenant-ID header - 2. tenant_id query parameter - 3. tenant_id in request path - """ - # Method 1: Header - tenant_id = request.headers.get("X-Tenant-ID") - if tenant_id: - return tenant_id - - # Method 2: Query parameter - tenant_id = request.query_params.get("tenant_id") - if tenant_id: - return tenant_id - - # Method 3: Path parameter (extract from URLs like /api/v1/tenants/{tenant_id}/...) - path_parts = request.url.path.split("/") - if "tenants" in path_parts: - try: - tenant_index = path_parts.index("tenants") - if tenant_index + 1 < len(path_parts): - return path_parts[tenant_index + 1] - except (ValueError, IndexError): - pass - - return None - async def _verify_token(self, token: str) -> Optional[Dict[str, Any]]: - """ - Verify JWT token with fallback strategy: - 1. Local validation (fast) - 2. Auth service validation (authoritative) - 3. Cache valid tokens to reduce auth service calls - """ + """Verify JWT token with fallback strategy""" - # Step 1: Try local JWT validation first (fast) + # Try local JWT validation first (fast) try: payload = jwt_handler.verify_token(token) if payload and self._validate_token_payload(payload): @@ -165,7 +140,7 @@ class AuthMiddleware(BaseHTTPMiddleware): except Exception as e: logger.debug(f"Local token validation failed: {e}") - # Step 2: Check cache for recently validated tokens + # Check cache for recently validated tokens if self.redis_client: try: cached_user = await self._get_cached_user(token) @@ -175,7 +150,7 @@ class AuthMiddleware(BaseHTTPMiddleware): except Exception as e: logger.warning(f"Cache lookup failed: {e}") - # Step 3: Verify with auth service (authoritative) + # Verify with auth service (authoritative) try: user_context = await self._verify_with_auth_service(token) if user_context: @@ -186,7 +161,7 @@ class AuthMiddleware(BaseHTTPMiddleware): return user_context except Exception as e: logger.error(f"Auth service validation failed: {e}") - + return None def _validate_token_payload(self, payload: Dict[str, Any]) -> bool: @@ -197,6 +172,7 @@ class AuthMiddleware(BaseHTTPMiddleware): async def _verify_with_auth_service(self, token: str) -> Optional[Dict[str, Any]]: """Verify token with auth service""" try: + import httpx async with httpx.AsyncClient(timeout=3.0) as client: response = await client.post( f"{settings.AUTH_SERVICE_URL}/api/v1/auth/verify", @@ -209,35 +185,25 @@ class AuthMiddleware(BaseHTTPMiddleware): logger.warning(f"Auth service returned {response.status_code}") return None - except asyncio.TimeoutError: - logger.error("Auth service timeout") - return None except Exception as e: logger.error(f"Auth service error: {e}") return None - async def _verify_tenant_access(self, user_id: str, tenant_id: str) -> bool: - """Verify user has access to specific tenant""" - try: - async with httpx.AsyncClient(timeout=3.0) as client: - response = await client.get( - f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/access/{user_id}" - ) - return response.status_code == 200 - except Exception as e: - logger.error(f"Tenant access verification failed: {e}") - return False - async def _get_cached_user(self, token: str) -> Optional[Dict[str, Any]]: """Get user context from cache""" if not self.redis_client: return None cache_key = f"auth:token:{hash(token)}" - cached_data = await self.redis_client.get(cache_key) - if cached_data: - import json - return json.loads(cached_data) + try: + cached_data = await self.redis_client.get(cache_key) + if cached_data: + import json + if isinstance(cached_data, bytes): + cached_data = cached_data.decode() + return json.loads(cached_data) + except Exception as e: + logger.warning(f"Cache get failed: {e}") return None async def _cache_user(self, token: str, user_context: Dict[str, Any], ttl: int = 300): @@ -246,45 +212,45 @@ class AuthMiddleware(BaseHTTPMiddleware): return cache_key = f"auth:token:{hash(token)}" - import json - await self.redis_client.setex(cache_key, ttl, json.dumps(user_context)) + try: + import json + await self.redis_client.setex(cache_key, ttl, json.dumps(user_context)) + except Exception as e: + logger.warning(f"Cache set failed: {e}") - def _inject_auth_headers(self, request: Request, user_context: Dict[str, Any], tenant_id: Optional[str]): - """ - Inject authentication headers for downstream services - - This allows services to work both: - 1. Behind the gateway (using request.state) - 2. Called directly (using headers) for development/testing - """ - # Remove any existing auth headers to prevent spoofing - headers_to_remove = [ - "x-user-id", "x-user-email", "x-user-role", - "x-tenant-id", "x-user-permissions", "x-authenticated" + def _inject_context_headers(self, request: Request, user_context: Dict[str, Any], tenant_id: Optional[str]): + """Inject authentication and tenant headers for downstream services""" + + # Remove any existing auth headers to prevent spoofing + headers_to_remove = [ + "x-user-id", "x-user-email", "x-user-role", + "x-tenant-id", "x-tenant-verified", "x-authenticated" + ] + + for header in headers_to_remove: + request.headers.__dict__["_list"] = [ + (k, v) for k, v in request.headers.raw + if k.lower() != header.lower() ] - - for header in headers_to_remove: - request.headers.__dict__["_list"] = [ - (k, v) for k, v in request.headers.raw - if k.lower() != header.lower() - ] - - # Inject new headers - new_headers = [ - (b"x-authenticated", b"true"), - (b"x-user-id", str(user_context.get("user_id", "")).encode()), - (b"x-user-email", str(user_context.get("email", "")).encode()), - (b"x-user-role", str(user_context.get("role", "user")).encode()), - ] - - if tenant_id: - new_headers.append((b"x-tenant-id", tenant_id.encode())) - - permissions = user_context.get("permissions", []) - if permissions: - new_headers.append((b"x-user-permissions", ",".join(permissions).encode())) - - # Add headers to request - request.headers.__dict__["_list"].extend(new_headers) - - logger.debug(f"Injected auth headers for user {user_context.get('email')}") + + # Inject new headers + new_headers = [ + (b"x-authenticated", b"true"), + (b"x-user-id", str(user_context.get("user_id", "")).encode()), + (b"x-user-email", str(user_context.get("email", "")).encode()), + (b"x-user-role", str(user_context.get("role", "user")).encode()), + ] + + # Add tenant context if verified + if tenant_id: + new_headers.extend([ + (b"x-tenant-id", tenant_id.encode()), + (b"x-tenant-verified", b"true") + ]) + + # Add headers to request + request.headers.__dict__["_list"].extend(new_headers) + + logger.debug(f"Injected context headers", + user_id=user_context.get("user_id"), + tenant_id=tenant_id) \ No newline at end of file diff --git a/gateway/app/routes/data.py b/gateway/app/routes/data.py deleted file mode 100644 index 122db84a..00000000 --- a/gateway/app/routes/data.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Data service routes for API Gateway - Authentication handled by gateway middleware""" - -from fastapi import APIRouter, Request, Response, HTTPException -from fastapi.responses import StreamingResponse -import httpx -import logging - -from app.core.config import settings - -logger = logging.getLogger(__name__) -router = APIRouter() - -@router.api_route("/sales/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) -async def proxy_sales(request: Request, path: str): - """Proxy sales data requests to data service""" - return await _proxy_request(request, f"/api/v1/sales/{path}") - -@router.api_route("/weather/{path:path}", methods=["GET", "POST", "OPTIONS"]) -async def proxy_weather(request: Request, path: str): - """Proxy weather requests to data service""" - return await _proxy_request(request, f"/api/v1/weather/{path}") - -@router.api_route("/traffic/{path:path}", methods=["GET", "POST", "OPTIONS"]) -async def proxy_traffic(request: Request, path: str): - """Proxy traffic requests to data service""" - return await _proxy_request(request, f"/api/v1/traffic/{path}") - -async def _proxy_request(request: Request, target_path: str): - """Proxy request to data service with user context""" - - # Handle OPTIONS requests directly for CORS - if request.method == "OPTIONS": - return Response( - status_code=200, - headers={ - "Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST, - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID", - "Access-Control-Allow-Credentials": "true", - "Access-Control-Max-Age": "86400" # Cache preflight for 24 hours - } - ) - - try: - url = f"{settings.DATA_SERVICE_URL}{target_path}" - - # Forward headers BUT add user context from gateway auth - headers = dict(request.headers) - headers.pop("host", None) # Remove host header - - # ✅ ADD USER CONTEXT FROM GATEWAY AUTHENTICATION - # Gateway middleware already verified the token and added user to request.state - if hasattr(request.state, 'user'): - headers["X-User-ID"] = str(request.state.user.get("user_id")) - headers["X-User-Email"] = request.state.user.get("email", "") - headers["X-Tenant-ID"] = str(request.state.user.get("tenant_id")) - headers["X-User-Roles"] = ",".join(request.state.user.get("roles", [])) - - # Get request body if present - body = None - if request.method in ["POST", "PUT", "PATCH"]: - body = await request.body() - - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.request( - method=request.method, - url=url, - params=request.query_params, - headers=headers, - content=body - ) - - # Return streaming response for large payloads - if int(response.headers.get("content-length", 0)) > 1024: - return StreamingResponse( - iter([response.content]), - status_code=response.status_code, - headers=dict(response.headers), - media_type=response.headers.get("content-type") - ) - else: - return response.json() if response.headers.get("content-type", "").startswith("application/json") else response.content - - except httpx.RequestError as e: - logger.error("Data service request failed", error=str(e)) - raise HTTPException(status_code=503, detail="Data service unavailable") - except Exception as e: - logger.error("Unexpected error in data proxy", error=str(e)) - raise HTTPException(status_code=500, detail="Internal server error") \ No newline at end of file diff --git a/gateway/app/routes/forecasting.py b/gateway/app/routes/forecasting.py deleted file mode 100644 index b59fc743..00000000 --- a/gateway/app/routes/forecasting.py +++ /dev/null @@ -1,74 +0,0 @@ -# ================================================================ -# Gateway Integration: Update gateway/app/routes/forecasting.py -# ================================================================ -"""Forecasting service routes for API Gateway""" - -from fastapi import APIRouter, Request -import httpx -import logging - -from app.core.config import settings - -logger = logging.getLogger(__name__) -router = APIRouter() - -@router.api_route("/forecasts/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) -async def proxy_forecasts(request: Request, path: str): - """Proxy forecast requests to forecasting service""" - return await _proxy_request(request, f"/api/v1/forecasts/{path}") - -@router.api_route("/predictions/{path:path}", methods=["GET", "POST", "OPTIONS"]) -async def proxy_predictions(request: Request, path: str): - """Proxy prediction requests to forecasting service""" - return await _proxy_request(request, f"/api/v1/predictions/{path}") - -async def _proxy_request(request: Request, target_path: str): - """Proxy request to forecasting service with user context""" - - # Handle OPTIONS requests directly for CORS - if request.method == "OPTIONS": - return Response( - status_code=200, - headers={ - "Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST, - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID", - "Access-Control-Allow-Credentials": "true", - "Access-Control-Max-Age": "86400" # Cache preflight for 24 hours - } - ) - - try: - url = f"{settings.FORECASTING_SERVICE_URL}{target_path}" - - # Forward headers and add user context - headers = dict(request.headers) - headers.pop("host", None) - - # Add user context from gateway authentication - if hasattr(request.state, 'user'): - headers["X-User-ID"] = str(request.state.user.get("user_id")) - headers["X-User-Email"] = request.state.user.get("email", "") - headers["X-Tenant-ID"] = str(request.state.user.get("tenant_id")) - headers["X-User-Roles"] = ",".join(request.state.user.get("roles", [])) - - # Get request body if present - body = None - if request.method in ["POST", "PUT", "PATCH"]: - body = await request.body() - - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.request( - method=request.method, - url=url, - headers=headers, - content=body, - params=request.query_params - ) - - # Return response - return response.json() if response.headers.get("content-type", "").startswith("application/json") else response.text - - except Exception as e: - logger.error(f"Error proxying to forecasting service: {e}") - raise diff --git a/gateway/app/routes/tenant.py b/gateway/app/routes/tenant.py index c38177cb..2bb140bb 100644 --- a/gateway/app/routes/tenant.py +++ b/gateway/app/routes/tenant.py @@ -1,156 +1,200 @@ +# gateway/app/routes/tenant.py - COMPLETELY UPDATED """ -Tenant routes for gateway - FIXED VERSION +Tenant routes for API Gateway - Handles all tenant-scoped endpoints """ -from fastapi import APIRouter, Request, HTTPException +from fastapi import APIRouter, Request, Response, HTTPException, Path from fastapi.responses import JSONResponse import httpx import logging +from typing import Optional from app.core.config import settings logger = logging.getLogger(__name__) router = APIRouter() +# ================================================================ +# TENANT MANAGEMENT ENDPOINTS +# ================================================================ + @router.post("/register") async def create_tenant(request: Request): """Proxy tenant creation to tenant service""" - try: - body = await request.body() - - # ✅ FIX: Forward all headers AND add user context from gateway auth - headers = dict(request.headers) - headers.pop("host", None) # Remove host header - - # ✅ ADD USER CONTEXT FROM GATEWAY AUTHENTICATION - # Gateway middleware already verified the token and added user to request.state - if hasattr(request.state, 'user'): - headers["X-User-ID"] = str(request.state.user.get("user_id")) - headers["X-User-Email"] = request.state.user.get("email", "") - headers["X-User-Role"] = request.state.user.get("role", "user") - - # Add tenant ID if it exists - if hasattr(request.state, 'tenant_id') and request.state.tenant_id: - headers["X-Tenant-ID"] = str(request.state.tenant_id) - elif request.state.user.get("tenant_id"): - headers["X-Tenant-ID"] = str(request.state.user.get("tenant_id")) - - roles = request.state.user.get("roles", []) - if roles: - headers["X-User-Roles"] = ",".join(roles) - - permissions = request.state.user.get("permissions", []) - if permissions: - headers["X-User-Permissions"] = ",".join(permissions) - - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.post( - f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/register", - content=body, - headers=headers - ) - - return JSONResponse( - status_code=response.status_code, - content=response.json() - ) - - except httpx.RequestError as e: - logger.error(f"Tenant service unavailable: {e}") - raise HTTPException( - status_code=503, - detail="Tenant service unavailable" - ) + return await _proxy_to_tenant_service(request, "/api/v1/tenants/register") -@router.get("/") -async def get_tenants(request: Request): - """Get tenants""" +@router.get("/{tenant_id}") +async def get_tenant(request: Request, tenant_id: str = Path(...)): + """Get specific tenant details""" + return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}") + +@router.put("/{tenant_id}") +async def update_tenant(request: Request, tenant_id: str = Path(...)): + """Update tenant details""" + return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}") + +@router.get("/{tenant_id}/members") +async def get_tenant_members(request: Request, tenant_id: str = Path(...)): + """Get tenant members""" + return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/members") + +# ================================================================ +# TENANT-SCOPED DATA SERVICE ENDPOINTS +# ================================================================ + +@router.api_route("/{tenant_id}/sales/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) +async def proxy_tenant_sales(request: Request, tenant_id: str = Path(...), path: str = ""): + """Proxy tenant sales requests to data service""" + target_path = f"/api/v1/tenants/{tenant_id}/sales/{path}".rstrip("/") + return await _proxy_to_data_service(request, target_path) + +@router.api_route("/{tenant_id}/weather/{path:path}", methods=["GET", "POST", "OPTIONS"]) +async def proxy_tenant_weather(request: Request, tenant_id: str = Path(...), path: str = ""): + """Proxy tenant weather requests to data service""" + target_path = f"/api/v1/tenants/{tenant_id}/weather/{path}".rstrip("/") + return await _proxy_to_data_service(request, target_path) + +@router.api_route("/{tenant_id}/analytics/{path:path}", methods=["GET", "POST", "OPTIONS"]) +async def proxy_tenant_analytics(request: Request, tenant_id: str = Path(...), path: str = ""): + """Proxy tenant analytics requests to data service""" + target_path = f"/api/v1/tenants/{tenant_id}/analytics/{path}".rstrip("/") + return await _proxy_to_data_service(request, target_path) + +# ================================================================ +# TENANT-SCOPED TRAINING SERVICE ENDPOINTS +# ================================================================ + +@router.api_route("/{tenant_id}/training/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) +async def proxy_tenant_training(request: Request, tenant_id: str = Path(...), path: str = ""): + """Proxy tenant training requests to training service""" + target_path = f"/api/v1/tenants/{tenant_id}/training/{path}".rstrip("/") + return await _proxy_to_training_service(request, target_path) + +@router.api_route("/{tenant_id}/models/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) +async def proxy_tenant_models(request: Request, tenant_id: str = Path(...), path: str = ""): + """Proxy tenant model requests to training service""" + target_path = f"/api/v1/tenants/{tenant_id}/models/{path}".rstrip("/") + return await _proxy_to_training_service(request, target_path) + +# ================================================================ +# TENANT-SCOPED FORECASTING SERVICE ENDPOINTS +# ================================================================ + +@router.api_route("/{tenant_id}/forecasts/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) +async def proxy_tenant_forecasts(request: Request, tenant_id: str = Path(...), path: str = ""): + """Proxy tenant forecast requests to forecasting service""" + target_path = f"/api/v1/tenants/{tenant_id}/forecasts/{path}".rstrip("/") + return await _proxy_to_forecasting_service(request, target_path) + +@router.api_route("/{tenant_id}/predictions/{path:path}", methods=["GET", "POST", "OPTIONS"]) +async def proxy_tenant_predictions(request: Request, tenant_id: str = Path(...), path: str = ""): + """Proxy tenant prediction requests to forecasting service""" + target_path = f"/api/v1/tenants/{tenant_id}/predictions/{path}".rstrip("/") + return await _proxy_to_forecasting_service(request, target_path) + +# ================================================================ +# TENANT-SCOPED NOTIFICATION SERVICE ENDPOINTS +# ================================================================ + +@router.api_route("/{tenant_id}/notifications/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) +async def proxy_tenant_notifications(request: Request, tenant_id: str = Path(...), path: str = ""): + """Proxy tenant notification requests to notification service""" + target_path = f"/api/v1/tenants/{tenant_id}/notifications/{path}".rstrip("/") + return await _proxy_to_notification_service(request, target_path) + +# ================================================================ +# PROXY HELPER FUNCTIONS +# ================================================================ + +async def _proxy_to_tenant_service(request: Request, target_path: str): + """Proxy request to tenant service""" + return await _proxy_request(request, target_path, settings.TENANT_SERVICE_URL) + +async def _proxy_to_data_service(request: Request, target_path: str): + """Proxy request to data service""" + return await _proxy_request(request, target_path, settings.DATA_SERVICE_URL) + +async def _proxy_to_training_service(request: Request, target_path: str): + """Proxy request to training service""" + return await _proxy_request(request, target_path, settings.TRAINING_SERVICE_URL) + +async def _proxy_to_forecasting_service(request: Request, target_path: str): + """Proxy request to forecasting service""" + return await _proxy_request(request, target_path, settings.FORECASTING_SERVICE_URL) + +async def _proxy_to_notification_service(request: Request, target_path: str): + """Proxy request to notification service""" + return await _proxy_request(request, target_path, settings.NOTIFICATION_SERVICE_URL) + +async def _proxy_request(request: Request, target_path: str, service_url: str): + """Generic proxy function with enhanced error handling""" + + # Handle OPTIONS requests directly for CORS + if request.method == "OPTIONS": + return Response( + status_code=200, + headers={ + "Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST, + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "86400" + } + ) + try: - # ✅ FIX: Same pattern for GET requests + url = f"{service_url}{target_path}" + + # Forward headers and add user/tenant context headers = dict(request.headers) headers.pop("host", None) - # Add user context from gateway auth - if hasattr(request.state, 'user'): - headers["X-User-ID"] = str(request.state.user.get("user_id")) - headers["X-User-Email"] = request.state.user.get("email", "") - headers["X-User-Role"] = request.state.user.get("role", "user") - - if hasattr(request.state, 'tenant_id') and request.state.tenant_id: - headers["X-Tenant-ID"] = str(request.state.tenant_id) - elif request.state.user.get("tenant_id"): - headers["X-Tenant-ID"] = str(request.state.user.get("tenant_id")) - - roles = request.state.user.get("roles", []) - if roles: - headers["X-User-Roles"] = ",".join(roles) - - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get( - f"{settings.TENANT_SERVICE_URL}/api/v1/tenants", - headers=headers - ) - - return JSONResponse( - status_code=response.status_code, - content=response.json() - ) - - except httpx.RequestError as e: - logger.error(f"Tenant service unavailable: {e}") - raise HTTPException( - status_code=503, - detail="Tenant service unavailable" - ) - -# ✅ ADD: Generic proxy function like the data service has -async def _proxy_tenant_request(request: Request, target_path: str, method: str = None): - """Proxy request to tenant service with user context""" - try: - url = f"{settings.TENANT_SERVICE_URL}{target_path}" - - # Forward headers with user context - headers = dict(request.headers) - headers.pop("host", None) - - # Add user context from gateway authentication - if hasattr(request.state, 'user'): - headers["X-User-ID"] = str(request.state.user.get("user_id")) - headers["X-User-Email"] = request.state.user.get("email", "") - headers["X-User-Role"] = request.state.user.get("role", "user") - - if hasattr(request.state, 'tenant_id') and request.state.tenant_id: - headers["X-Tenant-ID"] = str(request.state.tenant_id) - elif request.state.user.get("tenant_id"): - headers["X-Tenant-ID"] = str(request.state.user.get("tenant_id")) - - roles = request.state.user.get("roles", []) - if roles: - headers["X-User-Roles"] = ",".join(roles) - # Get request body if present body = None - request_method = method or request.method - if request_method in ["POST", "PUT", "PATCH"]: + if request.method in ["POST", "PUT", "PATCH"]: body = await request.body() + # Add query parameters + params = dict(request.query_params) + async with httpx.AsyncClient(timeout=30.0) as client: response = await client.request( - method=request_method, + method=request.method, url=url, headers=headers, content=body, - params=dict(request.query_params) + params=params ) + # Handle different response types + if response.headers.get("content-type", "").startswith("application/json"): + try: + content = response.json() + except: + content = {"message": "Invalid JSON response from service"} + else: + content = response.text + return JSONResponse( status_code=response.status_code, - content=response.json() + content=content ) + except httpx.TimeoutError: + logger.error(f"Timeout calling {service_url}{target_path}") + raise HTTPException( + status_code=504, + detail=f"Service timeout" + ) except httpx.RequestError as e: - logger.error(f"Tenant service unavailable: {e}") + logger.error(f"Request error calling {service_url}{target_path}: {e}") raise HTTPException( status_code=503, - detail="Tenant service unavailable" + detail=f"Service unavailable" + ) + except Exception as e: + logger.error(f"Unexpected error proxying to {service_url}{target_path}: {e}") + raise HTTPException( + status_code=500, + detail="Internal gateway error" ) \ No newline at end of file diff --git a/gateway/app/routes/training.py b/gateway/app/routes/training.py deleted file mode 100644 index a3bcab11..00000000 --- a/gateway/app/routes/training.py +++ /dev/null @@ -1,100 +0,0 @@ -""" -Training routes for gateway - FIXED VERSION -""" - -from fastapi import APIRouter, Request, HTTPException, Query, Response -from fastapi.responses import JSONResponse -import httpx -import logging -from typing import Optional - -from app.core.config import settings - -logger = logging.getLogger(__name__) -router = APIRouter() - -async def _proxy_training_request(request: Request, target_path: str, method: str = None): - """Proxy request to training service with user context""" - - # Handle OPTIONS requests directly for CORS - if request.method == "OPTIONS": - return Response( - status_code=200, - headers={ - "Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST, - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID", - "Access-Control-Allow-Credentials": "true", - "Access-Control-Max-Age": "86400" # Cache preflight for 24 hours - } - ) - - try: - url = f"{settings.TRAINING_SERVICE_URL}{target_path}" - - # Forward headers AND add user context from gateway auth - headers = dict(request.headers) - headers.pop("host", None) # Remove host header - - # ✅ ADD USER CONTEXT FROM GATEWAY AUTHENTICATION - # Gateway middleware already verified the token and added user to request.state - if hasattr(request.state, 'user'): - headers["X-User-ID"] = str(request.state.user.get("user_id")) - headers["X-User-Email"] = request.state.user.get("email", "") - headers["X-Tenant-ID"] = str(request.state.user.get("tenant_id")) - headers["X-User-Roles"] = ",".join(request.state.user.get("roles", [])) - headers["X-User-Permissions"] = ",".join(request.state.user.get("permissions", [])) - - # Get request body if present - body = None - request_method = method or request.method - if request_method in ["POST", "PUT", "PATCH"]: - body = await request.body() - - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.request( - method=request_method, - url=url, - headers=headers, - content=body, - params=dict(request.query_params) - ) - - return JSONResponse( - status_code=response.status_code, - content=response.json() - ) - - except httpx.RequestError as e: - logger.error(f"Training service unavailable: {e}") - raise HTTPException( - status_code=503, - detail="Training service unavailable" - ) - except Exception as e: - logger.error(f"Training service error: {e}") - raise HTTPException(status_code=500, detail="Internal server error") - -@router.get("/status/{training_job_id}") -async def get_training_status(training_job_id: str, request: Request): - """Get training job status""" - return await _proxy_training_request(request, f"/training/status/{training_job_id}", "GET") - -@router.get("/models") -async def get_trained_models(request: Request): - """Get trained models""" - return await _proxy_training_request(request, "/training/models", "GET") - -@router.get("/jobs") -async def get_training_jobs( - request: Request, - limit: Optional[int] = Query(10, ge=1, le=100), - offset: Optional[int] = Query(0, ge=0) -): - """Get training jobs""" - return await _proxy_training_request(request, f"/training/jobs?limit={limit}&offset={offset}", "GET") - -@router.post("/jobs") -async def start_training_job(request: Request): - """Start a new training job - Proxy to training service""" - return await _proxy_training_request(request, "/training/jobs", "POST") \ No newline at end of file diff --git a/services/auth/app/api/auth.py b/services/auth/app/api/auth.py index aa5afcf8..d676fc1c 100644 --- a/services/auth/app/api/auth.py +++ b/services/auth/app/api/auth.py @@ -21,7 +21,7 @@ from app.core.security import SecurityManager from shared.monitoring.decorators import track_execution_time logger = structlog.get_logger() -router = APIRouter() +router = APIRouter(tags=["authentication"]) security = HTTPBearer(auto_error=False) def get_metrics_collector(request: Request): diff --git a/services/auth/app/api/users.py b/services/auth/app/api/users.py index a20b2122..ac446cbf 100644 --- a/services/auth/app/api/users.py +++ b/services/auth/app/api/users.py @@ -21,7 +21,7 @@ from shared.auth.decorators import ( ) logger = structlog.get_logger() -router = APIRouter() +router = APIRouter(tags=["users"]) @router.get("/me", response_model=UserResponse) async def get_current_user_info( @@ -77,46 +77,4 @@ async def update_current_user( raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update user" - ) - -@router.post("/change-password") -async def change_password( - password_data: PasswordChange, - current_user: Dict[str, Any] = Depends(get_current_user_dep), - db: AsyncSession = Depends(get_db) -): - """Change user password""" - try: - await UserService.change_password( - current_user.id, - password_data.current_password, - password_data.new_password, - db - ) - return {"message": "Password changed successfully"} - except HTTPException: - raise - except Exception as e: - logger.error(f"Password change error: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to change password" - ) - -@router.delete("/me") -async def delete_current_user( - current_user: Dict[str, Any] = Depends(get_current_user_dep), - db: AsyncSession = Depends(get_db) -): - """Delete current user account""" - try: - await UserService.delete_user(current_user.id, db) - return {"message": "User account deleted successfully"} - except HTTPException: - raise - except Exception as e: - logger.error(f"Delete user error: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to delete user account" ) \ No newline at end of file diff --git a/services/data/app/api/sales.py b/services/data/app/api/sales.py index cae1c1f5..8fac8fff 100644 --- a/services/data/app/api/sales.py +++ b/services/data/app/api/sales.py @@ -1,13 +1,13 @@ # ================================================================ -# services/data/app/api/sales.py - UPDATED WITH UNIFIED AUTH +# services/data/app/api/sales.py - FIXED FOR NEW TENANT-SCOPED ARCHITECTURE # ================================================================ -"""Sales data API endpoints with unified authentication""" +"""Sales data API endpoints with tenant-scoped URLs""" -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query, Response +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query, Response, Path from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from typing import List, Optional, Dict, Any -import uuid +from uuid import UUID from datetime import datetime import base64 import structlog @@ -31,22 +31,23 @@ from app.services.messaging import ( ) # Import unified authentication from shared library -from shared.auth.decorators import ( - get_current_user_dep, - get_current_tenant_id_dep -) +from shared.auth.decorators import get_current_user_dep router = APIRouter(tags=["sales"]) logger = structlog.get_logger() -@router.post("/", response_model=SalesDataResponse) +# ================================================================ +# TENANT-SCOPED SALES ENDPOINTS +# ================================================================ + +@router.post("/tenants/{tenant_id}/sales", response_model=SalesDataResponse) async def create_sales_record( sales_data: SalesDataCreate, - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): - """Create a new sales record""" + """Create a new sales record for tenant""" try: logger.debug("Creating sales record", product=sales_data.product_name, @@ -54,7 +55,7 @@ async def create_sales_record( tenant_id=tenant_id, user_id=current_user["user_id"]) - # Override tenant_id from token/header + # Override tenant_id from URL path (gateway already verified access) sales_data.tenant_id = tenant_id record = await SalesService.create_sales_record(sales_data, db) @@ -85,14 +86,14 @@ async def create_sales_record( tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to create sales record: {str(e)}") -@router.post("/bulk", response_model=List[SalesDataResponse]) +@router.post("/tenants/{tenant_id}/sales/bulk", response_model=List[SalesDataResponse]) async def create_bulk_sales( sales_data: List[SalesDataCreate], - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): - """Create multiple sales records""" + """Create multiple sales records for tenant""" try: logger.debug("Creating bulk sales records", count=len(sales_data), @@ -127,16 +128,16 @@ async def create_bulk_sales( tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to create bulk sales records: {str(e)}") -@router.get("/", response_model=List[SalesDataResponse]) +@router.get("/tenants/{tenant_id}/sales", response_model=List[SalesDataResponse]) async def get_sales_data( - start_date: Optional[datetime] = Query(None), - end_date: Optional[datetime] = Query(None), - product_name: Optional[str] = Query(None), - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), + start_date: Optional[datetime] = Query(None, description="Start date filter"), + end_date: Optional[datetime] = Query(None, description="End date filter"), + product_name: Optional[str] = Query(None, description="Product name filter"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): - """Get sales data with filters""" + """Get sales data for tenant with filters""" try: logger.debug("Querying sales data", tenant_id=tenant_id, @@ -164,15 +165,15 @@ async def get_sales_data( tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to query sales data: {str(e)}") -@router.post("/import", response_model=SalesImportResult) +@router.post("/tenants/{tenant_id}/sales/import", response_model=SalesImportResult) async def import_sales_data( + tenant_id: UUID = Path(..., description="Tenant ID"), file: UploadFile = File(...), file_format: str = Form(...), - tenant_id: str = Depends(get_current_tenant_id_dep), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): - """Import sales data from file""" + """Import sales data from file for tenant""" try: logger.info("Importing sales data", tenant_id=tenant_id, @@ -220,26 +221,27 @@ async def import_sales_data( tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to import sales data: {str(e)}") -@router.post("/import/validate", response_model=SalesValidationResult) +@router.post("/tenants/{tenant_id}/sales/import/validate", response_model=SalesValidationResult) async def validate_import_data( import_data: SalesDataImport, - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep) ): - """Validate import data before processing""" + """Validate import data - Gateway already verified tenant access""" try: - logger.debug("Validating import data", tenant_id=tenant_id) + logger.debug("Validating import data", + tenant_id=tenant_id, + user_id=current_user["user_id"]) - # Override tenant_id + # Set tenant context from URL path import_data.tenant_id = tenant_id - validation = await DataImportService.validate_import_data( - import_data.model_dump() - ) + validation = await DataImportService.validate_import_data(import_data.model_dump()) logger.debug("Validation completed", is_valid=validation.get("is_valid", False), tenant_id=tenant_id) + return validation except Exception as e: @@ -248,15 +250,17 @@ async def validate_import_data( tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to validate import data: {str(e)}") -@router.get("/import/template/{format_type}") +@router.get("/tenants/{tenant_id}/sales/import/template/{format_type}") async def get_import_template( - format_type: str, + tenant_id: UUID = Path(..., description="Tenant ID"), + format_type: str = Path(..., description="Template format: csv, json, excel"), current_user: Dict[str, Any] = Depends(get_current_user_dep) ): """Get import template for specified format""" try: logger.debug("Getting import template", format=format_type, + tenant_id=tenant_id, user_id=current_user["user_id"]) template = await DataImportService.get_import_template(format_type) @@ -265,7 +269,9 @@ async def get_import_template( logger.warning("Template generation error", error=template["error"]) raise HTTPException(status_code=400, detail=template["error"]) - logger.debug("Template generated successfully", format=format_type) + logger.debug("Template generated successfully", + format=format_type, + tenant_id=tenant_id) if format_type.lower() == "csv": return Response( @@ -291,14 +297,16 @@ async def get_import_template( except HTTPException: raise except Exception as e: - logger.error("Failed to generate import template", error=str(e)) + logger.error("Failed to generate import template", + error=str(e), + tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to generate template: {str(e)}") -@router.get("/analytics") +@router.get("/tenants/{tenant_id}/sales/analytics") async def get_sales_analytics( + tenant_id: UUID = Path(..., description="Tenant ID"), start_date: Optional[datetime] = Query(None, description="Start date"), end_date: Optional[datetime] = Query(None, description="End date"), - tenant_id: str = Depends(get_current_tenant_id_dep), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): @@ -322,17 +330,17 @@ async def get_sales_analytics( tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to generate analytics: {str(e)}") -@router.post("/export") +@router.post("/tenants/{tenant_id}/sales/export") async def export_sales_data( + tenant_id: UUID = Path(..., description="Tenant ID"), export_format: str = Query("csv", description="Export format: csv, excel, json"), start_date: Optional[datetime] = Query(None, description="Start date"), end_date: Optional[datetime] = Query(None, description="End date"), products: Optional[List[str]] = Query(None, description="Filter by products"), - tenant_id: str = Depends(get_current_tenant_id_dep), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): - """Export sales data in specified format""" + """Export sales data in specified format for tenant""" try: logger.info("Exporting sales data", tenant_id=tenant_id, @@ -376,14 +384,14 @@ async def export_sales_data( tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to export sales data: {str(e)}") -@router.delete("/{record_id}") +@router.delete("/tenants/{tenant_id}/sales/{record_id}") async def delete_sales_record( - record_id: str, - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), + record_id: str = Path(..., description="Sales record ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): - """Delete a sales record""" + """Delete a sales record for tenant""" try: logger.info("Deleting sales record", record_id=record_id, @@ -413,14 +421,14 @@ async def delete_sales_record( tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to delete sales record: {str(e)}") -@router.get("/summary") +@router.get("/tenants/{tenant_id}/sales/summary") async def get_sales_summary( + tenant_id: UUID = Path(..., description="Tenant ID"), period: str = Query("daily", description="Summary period: daily, weekly, monthly"), - tenant_id: str = Depends(get_current_tenant_id_dep), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): - """Get sales summary for specified period""" + """Get sales summary for specified period for tenant""" try: logger.debug("Getting sales summary", tenant_id=tenant_id, @@ -437,13 +445,13 @@ async def get_sales_summary( tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to generate summary: {str(e)}") -@router.get("/products") +@router.get("/tenants/{tenant_id}/sales/products") async def get_products_list( - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): - """Get list of all products with sales data""" + """Get list of all products with sales data for tenant""" try: logger.debug("Getting products list", tenant_id=tenant_id) @@ -458,4 +466,78 @@ async def get_products_list( logger.error("Failed to get products list", error=str(e), tenant_id=tenant_id) - raise HTTPException(status_code=500, detail=f"Failed to get products list: {str(e)}") \ No newline at end of file + raise HTTPException(status_code=500, detail=f"Failed to get products list: {str(e)}") + +@router.get("/tenants/{tenant_id}/sales/{record_id}", response_model=SalesDataResponse) +async def get_sales_record( + tenant_id: UUID = Path(..., description="Tenant ID"), + record_id: str = Path(..., description="Sales record ID"), + current_user: Dict[str, Any] = Depends(get_current_user_dep), + db: AsyncSession = Depends(get_db) +): + """Get a specific sales record for tenant""" + try: + logger.debug("Getting sales record", + record_id=record_id, + tenant_id=tenant_id) + + record = await SalesService.get_sales_record(record_id, db) + + if not record or record.tenant_id != tenant_id: + raise HTTPException(status_code=404, detail="Sales record not found") + + logger.debug("Sales record retrieved", + record_id=record_id, + tenant_id=tenant_id) + return record + + except HTTPException: + raise + except Exception as e: + logger.error("Failed to get sales record", + error=str(e), + tenant_id=tenant_id, + record_id=record_id) + raise HTTPException(status_code=500, detail=f"Failed to get sales record: {str(e)}") + +@router.put("/tenants/{tenant_id}/sales/{record_id}", response_model=SalesDataResponse) +async def update_sales_record( + sales_data: SalesDataCreate, + record_id: str = Path(..., description="Sales record ID"), + tenant_id: UUID = Path(..., description="Tenant ID"), + current_user: Dict[str, Any] = Depends(get_current_user_dep), + db: AsyncSession = Depends(get_db) +): + """Update a sales record for tenant""" + try: + logger.info("Updating sales record", + record_id=record_id, + tenant_id=tenant_id, + user_id=current_user["user_id"]) + + # Verify record exists and belongs to tenant + existing_record = await SalesService.get_sales_record(record_id, db) + if not existing_record or existing_record.tenant_id != tenant_id: + raise HTTPException(status_code=404, detail="Sales record not found") + + # Override tenant_id from URL path + sales_data.tenant_id = tenant_id + + updated_record = await SalesService.update_sales_record(record_id, sales_data, db) + + if not updated_record: + raise HTTPException(status_code=404, detail="Sales record not found") + + logger.info("Sales record updated successfully", + record_id=record_id, + tenant_id=tenant_id) + return updated_record + + except HTTPException: + raise + except Exception as e: + logger.error("Failed to update sales record", + error=str(e), + tenant_id=tenant_id, + record_id=record_id) + raise HTTPException(status_code=500, detail=f"Failed to update sales record: {str(e)}") \ No newline at end of file diff --git a/services/data/app/api/traffic.py b/services/data/app/api/traffic.py index 0787c282..81da02db 100644 --- a/services/data/app/api/traffic.py +++ b/services/data/app/api/traffic.py @@ -3,11 +3,12 @@ # ================================================================ """Traffic data API endpoints with improved error handling""" -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import APIRouter, Depends, HTTPException, Query, Path from typing import List, Dict, Any from datetime import datetime, timedelta import structlog +from uuid import UUID +from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.services.traffic_service import TrafficService @@ -23,14 +24,15 @@ from shared.auth.decorators import ( get_current_tenant_id_dep ) -router = APIRouter() +router = APIRouter(tags=["traffic"]) traffic_service = TrafficService() logger = structlog.get_logger() -@router.get("/current", response_model=TrafficDataResponse) +@router.get("/tenants/{tenant_id}/current", response_model=TrafficDataResponse) async def get_current_traffic( latitude: float = Query(..., description="Latitude"), longitude: float = Query(..., description="Longitude"), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), ): """Get current traffic data for location""" @@ -69,13 +71,14 @@ async def get_current_traffic( logger.error("Traffic API traceback", traceback=traceback.format_exc()) raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") -@router.get("/historical", response_model=List[TrafficDataResponse]) +@router.get("/tenants/{tenant_id}/historical", response_model=List[TrafficDataResponse]) async def get_historical_traffic( latitude: float = Query(..., description="Latitude"), longitude: float = Query(..., description="Longitude"), start_date: datetime = Query(..., description="Start date"), end_date: datetime = Query(..., description="End date"), db: AsyncSession = Depends(get_db), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), ): """Get historical traffic data""" @@ -115,11 +118,12 @@ async def get_historical_traffic( logger.error("Unexpected error in historical traffic API", error=str(e)) raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") -@router.post("/store") +@router.post("/tenants/{tenant_id}/store") async def store_traffic_data( latitude: float = Query(..., description="Latitude"), longitude: float = Query(..., description="Longitude"), db: AsyncSession = Depends(get_db), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep) ): """Store current traffic data to database""" diff --git a/services/data/app/api/weather.py b/services/data/app/api/weather.py index 5e53567f..cb6a2a49 100644 --- a/services/data/app/api/weather.py +++ b/services/data/app/api/weather.py @@ -1,10 +1,11 @@ # services/data/app/api/weather.py - UPDATED WITH UNIFIED AUTH """Weather data API endpoints with unified authentication""" -from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks +from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks, Path from typing import List, Optional, Dict, Any from datetime import datetime, date import structlog +from uuid import UUID from app.schemas.external import ( WeatherDataResponse, @@ -19,14 +20,14 @@ from shared.auth.decorators import ( get_current_tenant_id_dep ) -router = APIRouter(prefix="/weather", tags=["weather"]) +router = APIRouter(tags=["weather"]) logger = structlog.get_logger() -@router.get("/current", response_model=WeatherDataResponse) +@router.get("/tenants/{tenant_id}/current", response_model=WeatherDataResponse) async def get_current_weather( latitude: float = Query(..., description="Latitude"), longitude: float = Query(..., description="Longitude"), - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), ): """Get current weather data for location""" @@ -64,12 +65,12 @@ async def get_current_weather( logger.error("Failed to get current weather", error=str(e)) raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") -@router.get("/forecast", response_model=List[WeatherForecastResponse]) +@router.get("/tenants/{tenant_id}/forecast", response_model=List[WeatherForecastResponse]) async def get_weather_forecast( latitude: float = Query(..., description="Latitude"), longitude: float = Query(..., description="Longitude"), days: int = Query(7, description="Number of forecast days", ge=1, le=14), - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), ): """Get weather forecast for location""" @@ -108,13 +109,13 @@ async def get_weather_forecast( logger.error("Failed to get weather forecast", error=str(e)) raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") -@router.get("/history", response_model=List[WeatherDataResponse]) +@router.get("/tenants/{tenant_id}/history", response_model=List[WeatherDataResponse]) async def get_weather_history( start_date: date = Query(..., description="Start date"), end_date: date = Query(..., description="End date"), latitude: float = Query(..., description="Latitude"), longitude: float = Query(..., description="Longitude"), - tenant_id: str = Depends(get_current_tenant_id_dep) + tenant_id: str = Path(..., description="Tenant ID") ): """Get historical weather data""" try: @@ -134,11 +135,11 @@ async def get_weather_history( logger.error("Failed to get weather history", error=str(e)) raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") -@router.post("/sync") +@router.post("/tenants/{tenant_id}/sync") async def sync_weather_data( background_tasks: BackgroundTasks, force: bool = Query(False, description="Force sync even if recently synced"), - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), ): """Manually trigger weather data synchronization""" diff --git a/services/data/app/main.py b/services/data/app/main.py index 270058df..19124429 100644 --- a/services/data/app/main.py +++ b/services/data/app/main.py @@ -108,9 +108,9 @@ app.add_middleware( ) # Include routers -app.include_router(sales_router, prefix="/api/v1/sales", tags=["sales"]) -app.include_router(weather_router, prefix="/api/v1/weather", tags=["weather"]) -app.include_router(traffic_router, prefix="/api/v1/traffic", tags=["traffic"]) +app.include_router(sales_router, prefix="/api/v1", tags=["sales"]) +app.include_router(weather_router, prefix="/api/v1", tags=["weather"]) +app.include_router(traffic_router, prefix="/api/v1", tags=["traffic"]) # Health check endpoint @app.get("/health") diff --git a/services/tenant/app/api/tenants.py b/services/tenant/app/api/tenants.py index 571cd5ec..5f680dfb 100644 --- a/services/tenant/app/api/tenants.py +++ b/services/tenant/app/api/tenants.py @@ -3,10 +3,11 @@ Tenant API endpoints """ -from fastapi import APIRouter, Depends, HTTPException, status, Request +from fastapi import APIRouter, Depends, HTTPException, status, Path from sqlalchemy.ext.asyncio import AsyncSession from typing import List, Dict, Any import structlog +from uuid import UUID from app.core.database import get_db from app.schemas.tenants import ( @@ -45,8 +46,8 @@ async def register_bakery( @router.get("/tenants/{tenant_id}/access/{user_id}", response_model=TenantAccessResponse) async def verify_tenant_access( - tenant_id: str, user_id: str, + tenant_id: UUID = Path(..., description="Tenant ID"), db: AsyncSession = Depends(get_db) ): """Verify if user has access to tenant - Called by Gateway""" @@ -62,7 +63,7 @@ async def verify_tenant_access( detail="Access verification failed" ) -@router.get("/users/{user_id}/tenants", response_model=List[TenantResponse]) +@router.get("/tenants/users/{user_id}", response_model=List[TenantResponse]) async def get_user_tenants( user_id: str, current_user: Dict[str, Any] = Depends(get_current_user_dep), @@ -89,7 +90,7 @@ async def get_user_tenants( @router.get("/tenants/{tenant_id}", response_model=TenantResponse) async def get_tenant( - tenant_id: str, + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): @@ -113,8 +114,8 @@ async def get_tenant( @router.put("/tenants/{tenant_id}", response_model=TenantResponse) async def update_tenant( - tenant_id: str, update_data: TenantUpdate, + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): @@ -134,9 +135,9 @@ async def update_tenant( @router.post("/tenants/{tenant_id}/members", response_model=TenantMemberResponse) async def add_team_member( - tenant_id: str, user_id: str, role: str, + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): diff --git a/services/tenant/app/main.py b/services/tenant/app/main.py index a79dd3ba..5c69dced 100644 --- a/services/tenant/app/main.py +++ b/services/tenant/app/main.py @@ -89,7 +89,7 @@ async def health_check(): @app.get("/metrics") async def metrics(): """Prometheus metrics endpoint""" - return metrics_collector.generate_latest() + return metrics_collector.get_metrics() if __name__ == "__main__": import uvicorn diff --git a/services/training/app/api/training.py b/services/training/app/api/training.py index 9a469396..da6f8e42 100644 --- a/services/training/app/api/training.py +++ b/services/training/app/api/training.py @@ -3,11 +3,11 @@ # ================================================================ """Training API endpoints with unified authentication""" -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query, Path from typing import List, Optional, Dict, Any from datetime import datetime import structlog -import uuid +from uuid import UUID, uuid4 from app.schemas.training import ( TrainingJobRequest, @@ -49,7 +49,7 @@ def get_training_service() -> TrainingService: async def start_training_job( request: TrainingJobRequest, background_tasks: BackgroundTasks, - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), training_service: TrainingService = Depends(get_training_service), db: AsyncSession = Depends(get_db_session) # Ensure db is available @@ -57,11 +57,11 @@ async def start_training_job( """Start a new training job for all products""" try: - new_job_id = str(uuid.uuid4()) + new_job_id = str(uuid4()) logger.info("Starting training job", tenant_id=tenant_id, - job_id=uuid.uuid4(), + job_id=uuid4(), config=request.dict()) # Create training job @@ -115,7 +115,7 @@ async def get_training_jobs( status: Optional[TrainingStatus] = Query(None, description="Filter jobs by status"), limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0), - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), training_service: TrainingService = Depends(get_training_service) ): @@ -149,7 +149,7 @@ async def get_training_jobs( @router.get("/jobs/{job_id}", response_model=TrainingJobResponse) async def get_training_job( job_id: str, - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), training_service: TrainingService = Depends(get_training_service) ): @@ -182,7 +182,7 @@ async def get_training_job( @router.get("/jobs/{job_id}/progress", response_model=TrainingJobProgress) async def get_training_progress( job_id: str, - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), training_service: TrainingService = Depends(get_training_service) ): @@ -212,7 +212,7 @@ async def get_training_progress( @router.post("/jobs/{job_id}/cancel") async def cancel_training_job( job_id: str, - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), training_service: TrainingService = Depends(get_training_service) ): @@ -259,7 +259,7 @@ async def train_single_product( product_name: str, request: SingleProductTrainingRequest, background_tasks: BackgroundTasks, - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), training_service: TrainingService = Depends(get_training_service), db: AsyncSession = Depends(get_db_session) @@ -312,7 +312,7 @@ async def train_single_product( @router.post("/validate", response_model=DataValidationResponse) async def validate_training_data( request: DataValidationRequest, - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), training_service: TrainingService = Depends(get_training_service) ): @@ -343,7 +343,7 @@ async def validate_training_data( @router.get("/models") async def get_trained_models( product_name: Optional[str] = Query(None), - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), training_service: TrainingService = Depends(get_training_service) ): @@ -374,7 +374,7 @@ async def get_trained_models( @require_role("admin") # Only admins can delete models async def delete_model( model_id: str, - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), training_service: TrainingService = Depends(get_training_service) ): @@ -411,7 +411,7 @@ async def delete_model( async def get_training_stats( start_date: Optional[datetime] = Query(None), end_date: Optional[datetime] = Query(None), - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), training_service: TrainingService = Depends(get_training_service) ): @@ -442,7 +442,7 @@ async def get_training_stats( async def retrain_all_products( request: TrainingJobRequest, background_tasks: BackgroundTasks, - tenant_id: str = Depends(get_current_tenant_id_dep), + tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), training_service: TrainingService = Depends(get_training_service) ): diff --git a/services/training/app/main.py b/services/training/app/main.py index 54cd736c..256a1766 100644 --- a/services/training/app/main.py +++ b/services/training/app/main.py @@ -173,18 +173,10 @@ async def global_exception_handler(request: Request, exc: Exception): } ) -# Include API routers - NO AUTH DEPENDENCIES HERE -# Authentication is handled by API Gateway -app.include_router( - training.router, - tags=["training"] -) +# Include API routers +app.include_router(training.router, prefix="/api/v1", tags=["training"]) +app.include_router(models.router, prefix="/api/v1", tags=["models"]) -app.include_router( - models.router, - prefix="/models", - tags=["models"] -) # Health check endpoints @app.get("/health") diff --git a/services/training/app/services/training_service.py b/services/training/app/services/training_service.py index 7dec211c..004ffe65 100644 --- a/services/training/app/services/training_service.py +++ b/services/training/app/services/training_service.py @@ -488,7 +488,7 @@ class TrainingService: params["end_date"] = request.end_date.isoformat() response = await client.get( - f"{settings.DATA_SERVICE_URL}/weather/history", + f"{settings.DATA_SERVICE_URL}/tenants/{tenant_id}/weather/history", params=params, timeout=30.0 ) @@ -516,7 +516,7 @@ class TrainingService: params["end_date"] = request.end_date.isoformat() response = await client.get( - f"{settings.DATA_SERVICE_URL}/traffic/historical", + f"{settings.DATA_SERVICE_URL}/tenants/{tenant_id}/traffic/historical", params=params, timeout=30.0 ) diff --git a/shared/auth/decorators.py b/shared/auth/decorators.py index 213bc88c..543c1bf8 100644 --- a/shared/auth/decorators.py +++ b/shared/auth/decorators.py @@ -136,21 +136,21 @@ def get_current_tenant_id(request: Request) -> Optional[str]: def extract_user_from_headers(request: Request) -> Optional[Dict[str, Any]]: """Extract user information from forwarded headers (gateway sets these)""" - user_id = request.headers.get("X-User-ID") + user_id = request.headers.get("x-user-id") if not user_id: return None return { "user_id": user_id, - "email": request.headers.get("X-User-Email", ""), - "role": request.headers.get("X-User-Role", "user"), - "tenant_id": request.headers.get("X-Tenant-ID"), + "email": request.headers.get("x-user-email", ""), + "role": request.headers.get("x-user-role", "user"), + "tenant_id": request.headers.get("x-tenant-id"), "permissions": request.headers.get("X-User-Permissions", "").split(",") if request.headers.get("X-User-Permissions") else [] } def extract_tenant_from_headers(request: Request) -> Optional[str]: """Extract tenant ID from headers""" - return request.headers.get("X-Tenant-ID") + return request.headers.get("x-tenant-id") # FastAPI Dependencies for injection async def get_current_user_dep(request: Request) -> Dict[str, Any]: diff --git a/shared/auth/tenant_access.py b/shared/auth/tenant_access.py new file mode 100644 index 00000000..972d40db --- /dev/null +++ b/shared/auth/tenant_access.py @@ -0,0 +1,356 @@ +# ================================================================ +# shared/auth/tenant_access.py - Complete Implementation +# ================================================================ +""" +Tenant access control utilities for microservices +Provides both gateway-level and service-level tenant access verification +""" + +from typing import Dict, Any, Optional +import httpx +import structlog +from fastapi import HTTPException, Depends +import asyncio + +# Import FastAPI dependencies +from shared.auth.decorators import get_current_user_dep + +# Import settings (adjust import path based on your project structure) +try: + from app.core.config import settings +except ImportError: + try: + from core.config import settings + except ImportError: + # Fallback for different project structures + import os + class Settings: + TENANT_SERVICE_URL = os.getenv("TENANT_SERVICE_URL", "http://tenant-service:8000") + settings = Settings() + +# Setup logging +logger = structlog.get_logger() + +class TenantAccessManager: + """ + Centralized tenant access management for both gateway and service level + """ + + def __init__(self, redis_client=None): + """ + Initialize tenant access manager + + Args: + redis_client: Optional Redis client for caching + """ + self.redis_client = redis_client + + async def verify_basic_tenant_access(self, user_id: str, tenant_id: str) -> bool: + """ + Gateway-level: Basic tenant access verification with caching + + Args: + user_id: User ID to verify + tenant_id: Tenant ID to check access for + + Returns: + bool: True if user has access to tenant + """ + # Check cache first (5-minute TTL) + cache_key = f"tenant_access:{user_id}:{tenant_id}" + if self.redis_client: + try: + cached_result = await self.redis_client.get(cache_key) + if cached_result is not None: + return cached_result.decode() == "true" if isinstance(cached_result, bytes) else cached_result == "true" + except Exception as cache_error: + logger.warning(f"Cache lookup failed: {cache_error}") + + # Verify with tenant service + try: + async with httpx.AsyncClient(timeout=2.0) as client: # Short timeout for gateway + response = await client.get( + f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/access/{user_id}" + ) + + has_access = response.status_code == 200 + + # Cache result (5 minutes) + if self.redis_client: + try: + await self.redis_client.setex(cache_key, 300, "true" if has_access else "false") + except Exception as cache_error: + logger.warning(f"Cache set failed: {cache_error}") + + logger.debug(f"Tenant access check", + user_id=user_id, + tenant_id=tenant_id, + has_access=has_access) + + return has_access + + except asyncio.TimeoutError: + logger.error(f"Timeout verifying tenant access: user={user_id}, tenant={tenant_id}") + # Fail open for availability (let service handle detailed check) + return True + except httpx.RequestError as e: + logger.error(f"Request error verifying tenant access: {e}") + # Fail open for availability + return True + except Exception as e: + logger.error(f"Gateway tenant access verification failed: {e}") + # Fail open for availability (let service handle detailed check) + return True + + async def get_user_role_in_tenant(self, user_id: str, tenant_id: str) -> Optional[str]: + """ + Get user's role within a specific tenant + + Args: + user_id: User ID + tenant_id: Tenant ID + + Returns: + Optional[str]: User's role in tenant (owner, admin, manager, user) or None + """ + try: + async with httpx.AsyncClient(timeout=3.0) as client: + response = await client.get( + f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/members/{user_id}" + ) + if response.status_code == 200: + data = response.json() + role = data.get("role") + logger.debug(f"User role in tenant", + user_id=user_id, + tenant_id=tenant_id, + role=role) + return role + elif response.status_code == 404: + logger.debug(f"User not found in tenant", + user_id=user_id, + tenant_id=tenant_id) + return None + else: + logger.warning(f"Unexpected response getting user role: {response.status_code}") + return None + except Exception as e: + logger.error(f"Failed to get user role in tenant: {e}") + return None + + async def verify_resource_permission( + self, + user_id: str, + tenant_id: str, + resource: str, + action: str + ) -> bool: + """ + Fine-grained resource permission check (used by services) + + Args: + user_id: User ID + tenant_id: Tenant ID + resource: Resource type (sales, training, forecasts, etc.) + action: Action being performed (read, write, delete, etc.) + + Returns: + bool: True if user has permission + """ + user_role = await self.get_user_role_in_tenant(user_id, tenant_id) + + if not user_role: + return False + + # Role-based permission matrix + permissions = { + "owner": ["*"], # Owners can do everything + "admin": ["read", "write", "delete", "manage"], + "manager": ["read", "write"], + "user": ["read"] + } + + allowed_actions = permissions.get(user_role, []) + has_permission = "*" in allowed_actions or action in allowed_actions + + logger.debug(f"Resource permission check", + user_id=user_id, + tenant_id=tenant_id, + resource=resource, + action=action, + user_role=user_role, + has_permission=has_permission) + + return has_permission + + async def get_user_tenants(self, user_id: str) -> list: + """ + Get all tenants a user has access to + + Args: + user_id: User ID + + Returns: + list: List of tenant dictionaries + """ + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get( + f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/users/{user_id}" + ) + if response.status_code == 200: + tenants = response.json() + logger.debug(f"Retrieved user tenants", + user_id=user_id, + tenant_count=len(tenants)) + return tenants + else: + logger.warning(f"Failed to get user tenants: {response.status_code}") + return [] + except Exception as e: + logger.error(f"Failed to get user tenants: {e}") + return [] + +# Global instance for easy import +tenant_access_manager = TenantAccessManager() + + + +# ================================================================ +# FASTAPI DEPENDENCIES +# ================================================================ + +async def verify_tenant_access_dep( + tenant_id: str, + current_user: Dict[str, Any] = Depends(get_current_user_dep) +) -> str: + """ + FastAPI dependency to verify tenant access and return tenant_id + + Args: + tenant_id: Tenant ID from path parameter + current_user: Current user from auth dependency + + Returns: + str: Validated tenant_id + + Raises: + HTTPException: If user doesn't have access to tenant + """ + has_access = await tenant_access_manager.verify_user_tenant_access(current_user["user_id"], tenant_id) + if not has_access: + logger.warning(f"Access denied to tenant", + user_id=current_user["user_id"], + tenant_id=tenant_id) + raise HTTPException( + status_code=403, + detail=f"User {current_user['user_id']} does not have access to tenant {tenant_id}" + ) + + logger.debug(f"Tenant access verified", + user_id=current_user["user_id"], + tenant_id=tenant_id) + + return tenant_id + +async def verify_tenant_permission_dep( + tenant_id: str, + resource: str, + action: str, + current_user: Dict[str, Any] = Depends(get_current_user_dep) +) -> str: + """ + FastAPI dependency to verify tenant access AND resource permission + + Args: + tenant_id: Tenant ID from path parameter + resource: Resource type being accessed + action: Action being performed + current_user: Current user from auth dependency + + Returns: + str: Validated tenant_id + + Raises: + HTTPException: If user doesn't have access or permission + """ + # First verify basic tenant access + has_access = await tenant_access_manager.verify_user_tenant_access(current_user["user_id"], tenant_id) + if not has_access: + raise HTTPException( + status_code=403, + detail=f"Access denied to tenant {tenant_id}" + ) + + # Then verify specific resource permission + has_permission = await tenant_access_manager.verify_resource_permission( + current_user["user_id"], tenant_id, resource, action + ) + if not has_permission: + raise HTTPException( + status_code=403, + detail=f"Insufficient permissions for {action} on {resource}" + ) + + logger.debug(f"Tenant access and permission verified", + user_id=current_user["user_id"], + tenant_id=tenant_id, + resource=resource, + action=action) + + return tenant_id + +# ================================================================ +# UTILITY FUNCTIONS +# ================================================================ + +def extract_tenant_id_from_path(path: str) -> Optional[str]: + """ + Extract tenant_id from URL path like /api/v1/tenants/{tenant_id}/... + BUT NOT from tenant management endpoints like /api/v1/tenants/register + """ + path_parts = path.split("/") + if "tenants" in path_parts: + try: + tenant_index = path_parts.index("tenants") + if tenant_index + 1 < len(path_parts): + potential_tenant_id = path_parts[tenant_index + 1] + + # ✅ EXCLUDE tenant management endpoints + if potential_tenant_id in ["register", "list"]: + return None + + return potential_tenant_id + except (ValueError, IndexError): + pass + return None + +def is_tenant_scoped_path(path: str) -> bool: + """ + Check if path is tenant-scoped (contains /tenants/{tenant_id}/) + + Args: + path: URL path + + Returns: + bool: True if path is tenant-scoped + """ + return extract_tenant_id_from_path(path) is not None + +# ================================================================ +# EXPORTS +# ================================================================ + +__all__ = [ + # Classes + "TenantAccessManager", + "tenant_access_manager", + + # Dependencies + "verify_tenant_access_dep", + "verify_tenant_permission_dep", + + # Utilities + "extract_tenant_id_from_path", + "is_tenant_scoped_path" +] \ No newline at end of file diff --git a/test_new.sh b/test_new.sh new file mode 100755 index 00000000..9030aa2f --- /dev/null +++ b/test_new.sh @@ -0,0 +1,117 @@ +#!/bin/bash + +# Configuration +API_BASE="http://localhost:8000" +EMAIL="test@bakery.com" +PASSWORD="TestPassword123!" + +echo "🧪 Testing New Tenant-Scoped API Architecture" +echo "==============================================" + +# Step 1: Health Check +echo "1. Testing Gateway Health..." +curl -s -X GET "$API_BASE/health" | echo + +# Step 2: Register User +echo -e "\n2. Registering User..." +REGISTER_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/auth/register" \ + -H "Content-Type: application/json" \ + -d "{ + \"email\": \"$EMAIL\", + \"password\": \"$PASSWORD\", + \"full_name\": \"Test User\" + }") + +echo "Registration Response: $REGISTER_RESPONSE" + +# Step 3: Login +echo -e "\n3. Logging in..." +LOGIN_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d "{ + \"email\": \"$EMAIL\", + \"password\": \"$PASSWORD\" + }") + +# Extract token +ACCESS_TOKEN=$(echo "$LOGIN_RESPONSE" | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4) +echo "Login Response: $LOGIN_RESPONSE" +echo "Access Token: ${ACCESS_TOKEN:0:50}..." + +# ✅ NEW: Step 3.5 - Verify Token Works +echo -e "\n3.5. Verifying Access Token..." +TOKEN_TEST_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/auth/verify" \ + -H "Authorization: Bearer $ACCESS_TOKEN") + +echo "Token Verification Response: $TOKEN_TEST_RESPONSE" + +# Check if token verification was successful +if echo "$TOKEN_TEST_RESPONSE" | grep -q '"user_id"'; then + echo "✅ Token verification PASSED" +else + echo "❌ Token verification FAILED" + echo "Stopping test - token is not working" + exit 1 +fi + +# ✅ NEW: Step 3.6 - Test a Protected Endpoint +echo -e "\n3.6. Testing Protected Endpoint (User Profile)..." +USER_PROFILE_RESPONSE=$(curl -s -X GET "$API_BASE/api/v1/users/me" \ + -H "Authorization: Bearer $ACCESS_TOKEN") + +echo "User Profile Response: $USER_PROFILE_RESPONSE" + +# Check if protected endpoint works +if echo "$USER_PROFILE_RESPONSE" | grep -q '"email"'; then + echo "✅ Protected endpoint access PASSED" +else + echo "❌ Protected endpoint access FAILED" + echo "Response was: $USER_PROFILE_RESPONSE" + echo "Continuing with bakery registration anyway..." +fi + +# Step 4: Register Bakery +echo -e "\n4. Registering Bakery..." +echo "Using Token: ${ACCESS_TOKEN:0:50}..." +echo "Making request to: $API_BASE/api/v1/tenants/register" + +BAKERY_RESPONSE=$(curl -s -v -X POST "$API_BASE/api/v1/tenants/register" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -d '{ + "name": "Test Bakery API", + "business_type": "bakery", + "address": "Calle Test 123", + "city": "Madrid", + "postal_code": "28001", + "phone": "+34600123456" + }' 2>&1) + +echo "Full Response (including headers): $BAKERY_RESPONSE" + +# Extract tenant ID +TENANT_ID=$(echo "$BAKERY_RESPONSE" | grep -o '"id":"[^"]*"' | cut -d'"' -f4) +echo "Bakery Response: $BAKERY_RESPONSE" +echo "Tenant ID: $TENANT_ID" + +# Step 5: Test Tenant-Scoped Endpoint +echo -e "\n5. Testing Tenant Sales Endpoint..." +SALES_RESPONSE=$(curl -s -X GET "$API_BASE/api/v1/tenants/$TENANT_ID/sales" \ + -H "Authorization: Bearer $ACCESS_TOKEN") + +echo "Sales Response: $SALES_RESPONSE" + +# Step 6: Test Import Validation +echo -e "\n6. Testing Import Validation..." +VALIDATION_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/tenants/$TENANT_ID/sales/import/validate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -d '{ + "data": "date,product,quantity,revenue\n2024-01-01,bread,10,25.50", + "data_format": "csv" + }') + +echo "Validation Response: $VALIDATION_RESPONSE" + +echo -e "\n✅ API Test Complete!" +echo "If you see responses for each step, the new architecture is working!" \ No newline at end of file