REFACTOR API gateway

This commit is contained in:
Urtzi Alfaro
2025-07-26 18:46:52 +02:00
parent e49893e10a
commit e4885db828
24 changed files with 1049 additions and 1080 deletions

View File

@@ -99,7 +99,7 @@ export class DataService {
*/ */
async uploadSalesHistory( async uploadSalesHistory(
file: File, file: File,
tenantId?: string, tenantId: string, // Tenant ID is now a required path parameter
additionalData?: Record<string, any> additionalData?: Record<string, any>
): Promise<UploadResponse> { ): Promise<UploadResponse> {
try { try {
@@ -107,7 +107,7 @@ export class DataService {
// ✅ CRITICAL FIX: Use the correct endpoint that exists in backend // ✅ CRITICAL FIX: Use the correct endpoint that exists in backend
// Backend endpoint: @router.post("/import", response_model=SalesImportResult) // 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 // Determine file format
const fileName = file.name.toLowerCase(); const fileName = file.name.toLowerCase();
@@ -128,9 +128,10 @@ export class DataService {
formData.append('file', file); formData.append('file', file);
formData.append('file_format', fileFormat); formData.append('file_format', fileFormat);
if (tenantId) { // tenantId is no longer appended to FormData as it's a path parameter
formData.append('tenant_id', tenantId); // if (tenantId) {
} // formData.append('tenant_id', tenantId);
// }
// Add additional data if provided // Add additional data if provided
if (additionalData) { if (additionalData) {
@@ -143,7 +144,7 @@ export class DataService {
// ✅ FIXED: Use the correct endpoint that exists in the backend // ✅ FIXED: Use the correct endpoint that exists in the backend
const response = await apiClient.request<ApiResponse<any>>( const response = await apiClient.request<ApiResponse<any>>(
'/api/v1/data/sales/import', // Correct endpoint path `/api/v1/tenants/${tenantId}/sales/import`, // Correct endpoint path with tenant_id
{ {
method: 'POST', method: 'POST',
body: formData, body: formData,
@@ -216,7 +217,7 @@ export class DataService {
* ✅ ALTERNATIVE: Upload sales data using the JSON import endpoint * ✅ ALTERNATIVE: Upload sales data using the JSON import endpoint
* This uses the same endpoint as validation but with validate_only: false * This uses the same endpoint as validation but with validate_only: false
*/ */
async uploadSalesDataAsJson(file: File, tenantId?: string): Promise<UploadResponse> { async uploadSalesDataAsJson(file: File, tenantId: string): Promise<UploadResponse> { // tenantId made required
try { try {
console.log('Uploading sales data as JSON:', file.name); console.log('Uploading sales data as JSON:', file.name);
@@ -242,7 +243,7 @@ export class DataService {
// ✅ Use the same structure as validation but with validate_only: false // ✅ Use the same structure as validation but with validate_only: false
const importData: SalesDataImportRequest = { const importData: SalesDataImportRequest = {
tenant_id: tenantId || '00000000-0000-0000-0000-000000000000', tenant_id: tenantId, // Use the provided tenantId
data: fileContent, data: fileContent,
data_format: dataFormat, data_format: dataFormat,
validate_only: false, // This makes it actually import the data validate_only: false, // This makes it actually import the data
@@ -252,8 +253,16 @@ export class DataService {
console.log('Uploading data with validate_only: false'); console.log('Uploading data with validate_only: false');
// ✅ OPTION: Add a new JSON import endpoint to the backend // ✅ 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<ApiResponse<any>>( const response = await apiClient.post<ApiResponse<any>>(
'/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 importData
); );
@@ -312,7 +321,7 @@ export class DataService {
} }
} }
async validateSalesData(file: File, tenantId?: string): Promise<DataValidation> { async validateSalesData(file: File, tenantId: string): Promise<DataValidation> { // tenantId made required
try { try {
console.log('Reading file content...', file.name); console.log('Reading file content...', file.name);
@@ -342,7 +351,7 @@ export class DataService {
// ✅ FIXED: Use proper tenant ID when available // ✅ FIXED: Use proper tenant ID when available
const importData: SalesDataImportRequest = { const importData: SalesDataImportRequest = {
tenant_id: tenantId || '00000000-0000-0000-0000-000000000000', tenant_id: tenantId, // Use the provided tenantId
data: fileContent, data: fileContent,
data_format: dataFormat, data_format: dataFormat,
validate_only: true validate_only: true
@@ -351,7 +360,7 @@ export class DataService {
console.log('Sending validation request with tenant_id:', importData.tenant_id); console.log('Sending validation request with tenant_id:', importData.tenant_id);
const response = await apiClient.post<ApiResponse<DataValidation>>( const response = await apiClient.post<ApiResponse<DataValidation>>(
'/api/v1/data/sales/import/validate', `/api/v1/tenants/${tenantId}/sales/import/validate`, // Correct endpoint with tenant_id
importData importData
); );
@@ -524,7 +533,7 @@ export class DataService {
/** /**
* Get sales records * Get sales records
*/ */
async getSalesRecords(params?: { async getSalesRecords(tenantId: string, params?: { // Add tenantId
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
productName?: string; productName?: string;
@@ -536,16 +545,16 @@ export class DataService {
total: number; total: number;
page: number; page: number;
pages: number; pages: number;
}>>('/api/v1/data/sales', { params }); }>>(`/api/v1/tenants/${tenantId}/sales`, { params }); // Use tenantId in path
return response.data!; return response.data!;
} }
/** /**
* Create single sales record * Create single sales record
*/ */
async createSalesRecord(record: CreateSalesRequest): Promise<SalesRecord> { async createSalesRecord(tenantId: string, record: CreateSalesRequest): Promise<SalesRecord> { // Add tenantId
const response = await apiClient.post<ApiResponse<SalesRecord>>( const response = await apiClient.post<ApiResponse<SalesRecord>>(
'/api/v1/data/sales', `/api/v1/tenants/${tenantId}/sales`, // Use tenantId in path
record record
); );
return response.data!; return response.data!;
@@ -554,9 +563,9 @@ export class DataService {
/** /**
* Update sales record * Update sales record
*/ */
async updateSalesRecord(id: string, record: Partial<CreateSalesRequest>): Promise<SalesRecord> { async updateSalesRecord(tenantId: string, id: string, record: Partial<CreateSalesRequest>): Promise<SalesRecord> { // Add tenantId
const response = await apiClient.put<ApiResponse<SalesRecord>>( const response = await apiClient.put<ApiResponse<SalesRecord>>(
`/api/v1/data/sales/${id}`, `/api/v1/tenants/${tenantId}/sales/${id}`, // Use tenantId in path
record record
); );
return response.data!; return response.data!;
@@ -565,8 +574,8 @@ export class DataService {
/** /**
* Delete sales record * Delete sales record
*/ */
async deleteSalesRecord(id: string): Promise<void> { async deleteSalesRecord(tenantId: string, id: string): Promise<void> { // Add tenantId
await apiClient.delete(`/api/v1/data/sales/${id}`); await apiClient.delete(`/api/v1/tenants/${tenantId}/sales/${id}`); // Use tenantId in path
} }
/** /**

View File

@@ -142,303 +142,8 @@ export class TenantService {
* Corresponds to GET /users/{user_id}/tenants * Corresponds to GET /users/{user_id}/tenants
*/ */
async getUserTenants(userId: string): Promise<TenantInfo[]> { async getUserTenants(userId: string): Promise<TenantInfo[]> {
const response = await apiClient.get<ApiResponse<TenantInfo[]>>(`/api/v1/users/${userId}/tenants`); const response = await apiClient.get<ApiResponse<TenantInfo[]>>(`/api/v1/tenants/user/${userId}`);
return response.data!; 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<TenantMemberResponse> {
const response = await apiClient.post<ApiResponse<TenantMemberResponse>>(
`/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<TenantInfo> {
const response = await apiClient.get<ApiResponse<TenantInfo>>('/api/v1/tenants/current');
return response.data!;
}
/**
* Update current tenant (no direct backend mapping in provided file, but common)
*/
async updateCurrentTenant(updates: TenantUpdate): Promise<TenantInfo> {
const response = await apiClient.put<ApiResponse<TenantInfo>>('/api/v1/tenants/current', updates);
return response.data!;
}
/**
* Get tenant settings (no direct backend mapping in provided file)
*/
async getTenantSettings(): Promise<TenantSettings> {
const response = await apiClient.get<ApiResponse<TenantSettings>>('/api/v1/tenants/settings');
return response.data!;
}
/**
* Update tenant settings (no direct backend mapping in provided file)
*/
async updateTenantSettings(settings: Partial<TenantSettings>): Promise<TenantSettings> {
const response = await apiClient.put<ApiResponse<TenantSettings>>(
'/api/v1/tenants/settings',
settings
);
return response.data!;
}
/**
* Get tenant statistics (no direct backend mapping in provided file)
*/
async getTenantStats(): Promise<TenantStats> {
const response = await apiClient.get<ApiResponse<TenantStats>>('/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<ApiResponse<any>>('/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<ApiResponse<any>>('/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<TenantUser> {
const response = await apiClient.patch<ApiResponse<TenantUser>>(
`/api/v1/tenants/users/${userId}`,
{ role }
);
return response.data!;
}
/**
* Deactivate user (no direct backend mapping in provided file)
*/
async deactivateUser(userId: string): Promise<TenantUser> {
const response = await apiClient.patch<ApiResponse<TenantUser>>(
`/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<TenantUser> {
const response = await apiClient.patch<ApiResponse<TenantUser>>(
`/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<void> {
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<ApiResponse<any>>('/api/v1/tenants/invitations');
return response.data!;
}
/**
* Cancel invitation (no direct backend mapping in provided file)
*/
async cancelInvitation(invitationId: string): Promise<void> {
await apiClient.delete(`/api/v1/tenants/invitations/${invitationId}`);
}
/**
* Resend invitation (no direct backend mapping in provided file)
*/
async resendInvitation(invitationId: string): Promise<void> {
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<string, any>;
ip_address?: string;
user_agent?: string;
created_at: string;
}[];
total: number;
page: number;
pages: number;
}> {
const response = await apiClient.get<ApiResponse<any>>('/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<ApiResponse<any>>('/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<void> {
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<ApiResponse<any>>('/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<ApiResponse<any>>('/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<Blob> {
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<ApiResponse<any>>('/api/v1/tenants/current', {
data: { confirmation_token: confirmationToken },
});
return response.data!;
}
} }
export const tenantService = new TenantService();

View File

@@ -75,9 +75,9 @@ export class TrainingService {
/** /**
* Start new training job * Start new training job
*/ */
async startTraining(config: TrainingConfiguration): Promise<TrainingJobStatus> { async startTraining(tenantId: string, config: TrainingConfiguration): Promise<TrainingJobStatus> {
const response = await apiClient.post<TrainingJobStatus>( const response = await apiClient.post<TrainingJobStatus>(
'/api/v1/training/jobs', `/api/v1/tenants/${tenantId}/training/jobs`,
config config
); );
return response.data!; return response.data!;
@@ -86,9 +86,9 @@ export class TrainingService {
/** /**
* Get training job status * Get training job status
*/ */
async getTrainingStatus(jobId: string): Promise<TrainingJobProgress> { async getTrainingStatus(tenantId: string, jobId: string): Promise<TrainingJobProgress> {
const response = await apiClient.get<ApiResponse<TrainingJobProgress>>( const response = await apiClient.get<ApiResponse<TrainingJobProgress>>(
`/api/v1/training/jobs/${jobId}` `/api/v1/tenants/${tenantId}/training/jobs/${jobId}`
); );
return response.data!; return response.data!;
} }
@@ -96,7 +96,7 @@ export class TrainingService {
/** /**
* Get all training jobs * Get all training jobs
*/ */
async getTrainingHistory(params?: { async getTrainingHistory(tenantId: string, params?: {
page?: number; page?: number;
limit?: number; limit?: number;
status?: string; status?: string;
@@ -113,14 +113,14 @@ export class TrainingService {
/** /**
* Cancel training job * Cancel training job
*/ */
async cancelTraining(jobId: string): Promise<void> { async cancelTraining(tenantId: string, jobId: string): Promise<void> {
await apiClient.post(`/api/v1/training/jobs/${jobId}/cancel`); await apiClient.post(`/api/v1/tenants/${tenantId}/training/jobs/${jobId}/cancel`);
} }
/** /**
* Get trained models * Get trained models
*/ */
async getModels(params?: { async getModels(tenantId: string, params?: {
productName?: string; productName?: string;
active?: boolean; active?: boolean;
page?: number; page?: number;
@@ -131,14 +131,14 @@ export class TrainingService {
page: number; page: number;
pages: number; pages: number;
}> { }> {
const response = await apiClient.get<ApiResponse<any>>('/api/v1/training/models', { params }); const response = await apiClient.get<ApiResponse<any>>(`/api/v1/tenants/${tenantId}/training/models`, { params });
return response.data!; return response.data!;
} }
/** /**
* Get specific model details * Get specific model details
*/ */
async getModel(modelId: string): Promise<TrainedModel> { async getModel(tenantId: string, modelId: string): Promise<TrainedModel> {
const response = await apiClient.get<ApiResponse<TrainedModel>>( const response = await apiClient.get<ApiResponse<TrainedModel>>(
`/api/v1/training/models/${modelId}` `/api/v1/training/models/${modelId}`
); );
@@ -148,9 +148,9 @@ export class TrainingService {
/** /**
* Get model metrics * Get model metrics
*/ */
async getModelMetrics(modelId: string): Promise<ModelMetrics> { async getModelMetrics(tenantId: string, modelId: string): Promise<ModelMetrics> {
const response = await apiClient.get<ApiResponse<ModelMetrics>>( const response = await apiClient.get<ApiResponse<ModelMetrics>>(
`/api/v1/training/models/${modelId}/metrics` `/api/v1/tenants/${tenantId}/training/models/${modelId}/metrics`
); );
return response.data!; return response.data!;
} }
@@ -158,9 +158,9 @@ export class TrainingService {
/** /**
* Activate/deactivate model * Activate/deactivate model
*/ */
async toggleModelStatus(modelId: string, active: boolean): Promise<TrainedModel> { async toggleModelStatus(tenantId: string, modelId: string, active: boolean): Promise<TrainedModel> {
const response = await apiClient.patch<ApiResponse<TrainedModel>>( const response = await apiClient.patch<ApiResponse<TrainedModel>>(
`/api/v1/training/models/${modelId}`, `/api/v1/tenants/${tenantId}/training/models/${modelId}`,
{ is_active: active } { is_active: active }
); );
return response.data!; return response.data!;
@@ -169,16 +169,16 @@ export class TrainingService {
/** /**
* Delete model * Delete model
*/ */
async deleteModel(modelId: string): Promise<void> { async deleteModel(tenantId: string, modelId: string): Promise<void> {
await apiClient.delete(`/api/v1/training/models/${modelId}`); await apiClient.delete(`/api/v1/training/models/${modelId}`);
} }
/** /**
* Train specific product * Train specific product
*/ */
async trainProduct(productName: string, config?: Partial<TrainingConfiguration>): Promise<TrainingJobStatus> { async trainProduct(tenantId: string, productName: string, config?: Partial<TrainingConfiguration>): Promise<TrainingJobStatus> {
const response = await apiClient.post<ApiResponse<TrainingJobStatus>>( const response = await apiClient.post<ApiResponse<TrainingJobStatus>>(
'/api/v1/training/products/train', `/api/v1/tenants/${tenantId}/training/products/train`,
{ {
product_name: productName, product_name: productName,
...config, ...config,
@@ -190,7 +190,7 @@ export class TrainingService {
/** /**
* Get training statistics * Get training statistics
*/ */
async getTrainingStats(): Promise<{ async getTrainingStats(tenantId: string): Promise<{
total_models: number; total_models: number;
active_models: number; active_models: number;
avg_accuracy: number; avg_accuracy: number;
@@ -198,21 +198,21 @@ export class TrainingService {
products_trained: number; products_trained: number;
training_time_avg_minutes: number; training_time_avg_minutes: number;
}> { }> {
const response = await apiClient.get<ApiResponse<any>>('/api/v1/training/stats'); const response = await apiClient.get<ApiResponse<any>>(`/api/v1/tenants/${tenantId}/training/stats`);
return response.data!; return response.data!;
} }
/** /**
* Validate training data * Validate training data
*/ */
async validateTrainingData(products?: string[]): Promise<{ async validateTrainingData(tenantId: string, products?: string[]): Promise<{
valid: boolean; valid: boolean;
errors: string[]; errors: string[];
warnings: string[]; warnings: string[];
product_data_points: Record<string, number>; product_data_points: Record<string, number>;
recommendation: string; recommendation: string;
}> { }> {
const response = await apiClient.post<ApiResponse<any>>('/api/v1/training/validate', { const response = await apiClient.post<ApiResponse<any>>(`/api/v1/tenants/${tenantId}/training/validate`, {
products, products,
}); });
return response.data!; return response.data!;
@@ -221,29 +221,29 @@ export class TrainingService {
/** /**
* Get training recommendations * Get training recommendations
*/ */
async getTrainingRecommendations(): Promise<{ async getTrainingRecommendations(tenantId: string): Promise<{
should_retrain: boolean; should_retrain: boolean;
reasons: string[]; reasons: string[];
recommended_products: string[]; recommended_products: string[];
optimal_config: TrainingConfiguration; optimal_config: TrainingConfiguration;
}> { }> {
const response = await apiClient.get<ApiResponse<any>>('/api/v1/training/recommendations'); const response = await apiClient.get<ApiResponse<any>>(`/api/v1/tenants/${tenantId}/training/recommendations`);
return response.data!; return response.data!;
} }
/** /**
* Get training logs * Get training logs
*/ */
async getTrainingLogs(jobId: string): Promise<string[]> { async getTrainingLogs(tenantId: string, jobId: string): Promise<string[]> {
const response = await apiClient.get<ApiResponse<string[]>>(`/api/v1/training/jobs/${jobId}/logs`); const response = await apiClient.get<ApiResponse<string[]>>(`/api/v1/tenants/${tenantId}/training/jobs/${jobId}/logs`);
return response.data!; return response.data!;
} }
/** /**
* Export model * Export model
*/ */
async exportModel(modelId: string, format: 'pickle' | 'onnx' = 'pickle'): Promise<Blob> { async exportModel(tenantId: string, modelId: string, format: 'pickle' | 'onnx' = 'pickle'): Promise<Blob> {
const response = await apiClient.get(`/api/v1/training/models/${modelId}/export`, { const response = await apiClient.get(`/api/v1/tenants/${tenantId}/training/models/${modelId}/export`, {
params: { format }, params: { format },
responseType: 'blob', responseType: 'blob',
}); });

View File

@@ -137,7 +137,7 @@ const OnboardingPage = () => {
console.log('Starting training with config:', trainingConfig); console.log('Starting training with config:', trainingConfig);
// Start training via API // 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 // Update form data with training job ID
setFormData(prev => ({ setFormData(prev => ({
@@ -326,7 +326,7 @@ const OnboardingPage = () => {
hyperparameter_tuning: true hyperparameter_tuning: true
}; };
const trainingJob = await api.training.startTraining(trainingConfig); const trainingJob = await api.training.startTraining(currentTenantId, trainingConfig);
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
trainingTaskId: trainingJob.id, trainingTaskId: trainingJob.id,

View File

@@ -17,7 +17,7 @@ from app.core.service_discovery import ServiceDiscovery
from app.middleware.auth import AuthMiddleware from app.middleware.auth import AuthMiddleware
from app.middleware.logging import LoggingMiddleware from app.middleware.logging import LoggingMiddleware
from app.middleware.rate_limit import RateLimitMiddleware 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.logging import setup_logging
from shared.monitoring.metrics import MetricsCollector from shared.monitoring.metrics import MetricsCollector
@@ -56,10 +56,7 @@ app.add_middleware(AuthMiddleware)
# Include routers # Include routers
app.include_router(auth.router, prefix="/api/v1/auth", tags=["authentication"]) 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(user.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(tenant.router, prefix="/api/v1/tenants", tags=["tenants"]) 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(notification.router, prefix="/api/v1/notifications", tags=["notifications"])
app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"]) app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"])

View File

@@ -1,7 +1,6 @@
# gateway/app/middleware/auth.py - IMPROVED VERSION # gateway/app/middleware/auth.py
""" """
Enhanced Authentication Middleware for API Gateway Enhanced Authentication Middleware for API Gateway with Tenant Access Control
Implements proper token validation and tenant context extraction
""" """
import structlog import structlog
@@ -9,12 +8,11 @@ from fastapi import Request, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response from starlette.responses import Response
import httpx
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
import asyncio
from app.core.config import settings from app.core.config import settings
from shared.auth.jwt_handler import JWTHandler 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() logger = structlog.get_logger()
@@ -32,20 +30,12 @@ PUBLIC_ROUTES = [
"/api/v1/auth/register", "/api/v1/auth/register",
"/api/v1/auth/refresh", "/api/v1/auth/refresh",
"/api/v1/auth/verify", "/api/v1/auth/verify",
"/api/v1/tenant/register",
"/api/v1/nominatim/search" "/api/v1/nominatim/search"
] ]
class AuthMiddleware(BaseHTTPMiddleware): class AuthMiddleware(BaseHTTPMiddleware):
""" """
Enhanced Authentication Middleware following microservices best practices Enhanced Authentication Middleware with Tenant Access Control
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
""" """
def __init__(self, app, redis_client=None): def __init__(self, app, redis_client=None):
@@ -53,7 +43,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
self.redis_client = redis_client # For caching and rate limiting self.redis_client = redis_client # For caching and rate limiting
async def dispatch(self, request: Request, call_next) -> Response: 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) # Skip authentication for OPTIONS requests (CORS preflight)
if request.method == "OPTIONS": if request.method == "OPTIONS":
@@ -63,7 +53,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
if self._is_public_route(request.url.path): if self._is_public_route(request.url.path):
return await call_next(request) return await call_next(request)
# Extract and validate JWT token # ✅ STEP 1: Extract and validate JWT token
token = self._extract_token(request) token = self._extract_token(request)
if not token: if not token:
logger.warning(f"Missing token for protected route: {request.url.path}") logger.warning(f"Missing token for protected route: {request.url.path}")
@@ -72,7 +62,8 @@ class AuthMiddleware(BaseHTTPMiddleware):
content={"detail": "Authentication required"} 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) user_context = await self._verify_token(token)
if not user_context: if not user_context:
logger.warning(f"Invalid token for route: {request.url.path}") logger.warning(f"Invalid token for route: {request.url.path}")
@@ -81,28 +72,48 @@ class AuthMiddleware(BaseHTTPMiddleware):
content={"detail": "Invalid or expired token"} content={"detail": "Invalid or expired token"}
) )
# Extract tenant context from request (not from JWT) # ✅ STEP 3: Extract tenant context from URL using shared utility
tenant_id = self._extract_tenant_from_request(request) 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
)
# 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)
if not has_access: if not has_access:
logger.warning(f"User {user_context['email']} denied access to tenant {tenant_id}") logger.warning(f"User {user_context['email']} denied access to tenant {tenant_id}")
return JSONResponse( return JSONResponse(
status_code=403, status_code=403,
content={"detail": "Access denied to tenant"} content={"detail": f"Access denied to tenant {tenant_id}"}
) )
request.state.tenant_id = tenant_id
# Inject user context into request # Set tenant context in request state
request.state.tenant_id = tenant_id
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.user = user_context
request.state.authenticated = True request.state.authenticated = True
# Add user context to forwarded requests # ✅ STEP 6: Add context headers for downstream services
self._inject_auth_headers(request, user_context, tenant_id) self._inject_context_headers(request, user_context, tenant_id)
logger.debug(f"Authenticated request: {user_context['email']} -> {request.url.path}") logger.debug(f"Authenticated request",
user_email=user_context['email'],
tenant_id=tenant_id,
path=request.url.path)
return await call_next(request) return await call_next(request)
@@ -117,46 +128,10 @@ class AuthMiddleware(BaseHTTPMiddleware):
return auth_header.split(" ")[1] return auth_header.split(" ")[1]
return None 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]]: async def _verify_token(self, token: str) -> Optional[Dict[str, Any]]:
""" """Verify JWT token with fallback strategy"""
Verify JWT token with fallback strategy:
1. Local validation (fast)
2. Auth service validation (authoritative)
3. Cache valid tokens to reduce auth service calls
"""
# Step 1: Try local JWT validation first (fast) # Try local JWT validation first (fast)
try: try:
payload = jwt_handler.verify_token(token) payload = jwt_handler.verify_token(token)
if payload and self._validate_token_payload(payload): if payload and self._validate_token_payload(payload):
@@ -165,7 +140,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
except Exception as e: except Exception as e:
logger.debug(f"Local token validation failed: {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: if self.redis_client:
try: try:
cached_user = await self._get_cached_user(token) cached_user = await self._get_cached_user(token)
@@ -175,7 +150,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
except Exception as e: except Exception as e:
logger.warning(f"Cache lookup failed: {e}") logger.warning(f"Cache lookup failed: {e}")
# Step 3: Verify with auth service (authoritative) # Verify with auth service (authoritative)
try: try:
user_context = await self._verify_with_auth_service(token) user_context = await self._verify_with_auth_service(token)
if user_context: if user_context:
@@ -197,6 +172,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
async def _verify_with_auth_service(self, token: str) -> Optional[Dict[str, Any]]: async def _verify_with_auth_service(self, token: str) -> Optional[Dict[str, Any]]:
"""Verify token with auth service""" """Verify token with auth service"""
try: try:
import httpx
async with httpx.AsyncClient(timeout=3.0) as client: async with httpx.AsyncClient(timeout=3.0) as client:
response = await client.post( response = await client.post(
f"{settings.AUTH_SERVICE_URL}/api/v1/auth/verify", 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}") logger.warning(f"Auth service returned {response.status_code}")
return None return None
except asyncio.TimeoutError:
logger.error("Auth service timeout")
return None
except Exception as e: except Exception as e:
logger.error(f"Auth service error: {e}") logger.error(f"Auth service error: {e}")
return None 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]]: async def _get_cached_user(self, token: str) -> Optional[Dict[str, Any]]:
"""Get user context from cache""" """Get user context from cache"""
if not self.redis_client: if not self.redis_client:
return None return None
cache_key = f"auth:token:{hash(token)}" cache_key = f"auth:token:{hash(token)}"
cached_data = await self.redis_client.get(cache_key) try:
if cached_data: cached_data = await self.redis_client.get(cache_key)
import json if cached_data:
return json.loads(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 return None
async def _cache_user(self, token: str, user_context: Dict[str, Any], ttl: int = 300): async def _cache_user(self, token: str, user_context: Dict[str, Any], ttl: int = 300):
@@ -246,45 +212,45 @@ class AuthMiddleware(BaseHTTPMiddleware):
return return
cache_key = f"auth:token:{hash(token)}" cache_key = f"auth:token:{hash(token)}"
import json try:
await self.redis_client.setex(cache_key, ttl, json.dumps(user_context)) 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]): def _inject_context_headers(self, request: Request, user_context: Dict[str, Any], tenant_id: Optional[str]):
""" """Inject authentication and tenant headers for downstream services"""
Inject authentication headers for downstream services
This allows services to work both: # Remove any existing auth headers to prevent spoofing
1. Behind the gateway (using request.state) headers_to_remove = [
2. Called directly (using headers) for development/testing "x-user-id", "x-user-email", "x-user-role",
""" "x-tenant-id", "x-tenant-verified", "x-authenticated"
# Remove any existing auth headers to prevent spoofing ]
headers_to_remove = [
"x-user-id", "x-user-email", "x-user-role", for header in headers_to_remove:
"x-tenant-id", "x-user-permissions", "x-authenticated" request.headers.__dict__["_list"] = [
(k, v) for k, v in request.headers.raw
if k.lower() != header.lower()
] ]
for header in headers_to_remove: # Inject new headers
request.headers.__dict__["_list"] = [ new_headers = [
(k, v) for k, v in request.headers.raw (b"x-authenticated", b"true"),
if k.lower() != header.lower() (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()),
]
# Inject new headers # Add tenant context if verified
new_headers = [ if tenant_id:
(b"x-authenticated", b"true"), new_headers.extend([
(b"x-user-id", str(user_context.get("user_id", "")).encode()), (b"x-tenant-id", tenant_id.encode()),
(b"x-user-email", str(user_context.get("email", "")).encode()), (b"x-tenant-verified", b"true")
(b"x-user-role", str(user_context.get("role", "user")).encode()), ])
]
if tenant_id: # Add headers to request
new_headers.append((b"x-tenant-id", tenant_id.encode())) request.headers.__dict__["_list"].extend(new_headers)
permissions = user_context.get("permissions", []) logger.debug(f"Injected context headers",
if permissions: user_id=user_context.get("user_id"),
new_headers.append((b"x-user-permissions", ",".join(permissions).encode())) tenant_id=tenant_id)
# Add headers to request
request.headers.__dict__["_list"].extend(new_headers)
logger.debug(f"Injected auth headers for user {user_context.get('email')}")

View File

@@ -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")

View File

@@ -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

View File

@@ -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 from fastapi.responses import JSONResponse
import httpx import httpx
import logging import logging
from typing import Optional
from app.core.config import settings from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# ================================================================
# TENANT MANAGEMENT ENDPOINTS
# ================================================================
@router.post("/register") @router.post("/register")
async def create_tenant(request: Request): async def create_tenant(request: Request):
"""Proxy tenant creation to tenant service""" """Proxy tenant creation to tenant service"""
try: return await _proxy_to_tenant_service(request, "/api/v1/tenants/register")
body = await request.body()
# ✅ FIX: Forward all headers AND add user context from gateway auth @router.get("/{tenant_id}")
headers = dict(request.headers) async def get_tenant(request: Request, tenant_id: str = Path(...)):
headers.pop("host", None) # Remove host header """Get specific tenant details"""
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}")
# ✅ ADD USER CONTEXT FROM GATEWAY AUTHENTICATION @router.put("/{tenant_id}")
# Gateway middleware already verified the token and added user to request.state async def update_tenant(request: Request, tenant_id: str = Path(...)):
if hasattr(request.state, 'user'): """Update tenant details"""
headers["X-User-ID"] = str(request.state.user.get("user_id")) return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_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 @router.get("/{tenant_id}/members")
if hasattr(request.state, 'tenant_id') and request.state.tenant_id: async def get_tenant_members(request: Request, tenant_id: str = Path(...)):
headers["X-Tenant-ID"] = str(request.state.tenant_id) """Get tenant members"""
elif request.state.user.get("tenant_id"): return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/members")
headers["X-Tenant-ID"] = str(request.state.user.get("tenant_id"))
roles = request.state.user.get("roles", []) # ================================================================
if roles: # TENANT-SCOPED DATA SERVICE ENDPOINTS
headers["X-User-Roles"] = ",".join(roles) # ================================================================
permissions = request.state.user.get("permissions", []) @router.api_route("/{tenant_id}/sales/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
if permissions: async def proxy_tenant_sales(request: Request, tenant_id: str = Path(...), path: str = ""):
headers["X-User-Permissions"] = ",".join(permissions) """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)
async with httpx.AsyncClient(timeout=10.0) as client: @router.api_route("/{tenant_id}/weather/{path:path}", methods=["GET", "POST", "OPTIONS"])
response = await client.post( async def proxy_tenant_weather(request: Request, tenant_id: str = Path(...), path: str = ""):
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/register", """Proxy tenant weather requests to data service"""
content=body, target_path = f"/api/v1/tenants/{tenant_id}/weather/{path}".rstrip("/")
headers=headers return await _proxy_to_data_service(request, target_path)
)
return JSONResponse( @router.api_route("/{tenant_id}/analytics/{path:path}", methods=["GET", "POST", "OPTIONS"])
status_code=response.status_code, async def proxy_tenant_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
content=response.json() """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)
except httpx.RequestError as e: # ================================================================
logger.error(f"Tenant service unavailable: {e}") # TENANT-SCOPED TRAINING SERVICE ENDPOINTS
raise HTTPException( # ================================================================
status_code=503,
detail="Tenant service unavailable" @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"
}
) )
@router.get("/")
async def get_tenants(request: Request):
"""Get tenants"""
try: 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 = dict(request.headers)
headers.pop("host", None) 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 # Get request body if present
body = None 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() body = await request.body()
# Add query parameters
params = dict(request.query_params)
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.request( response = await client.request(
method=request_method, method=request.method,
url=url, url=url,
headers=headers, headers=headers,
content=body, 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( return JSONResponse(
status_code=response.status_code, 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: 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( raise HTTPException(
status_code=503, 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"
) )

View File

@@ -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")

View File

@@ -21,7 +21,7 @@ from app.core.security import SecurityManager
from shared.monitoring.decorators import track_execution_time from shared.monitoring.decorators import track_execution_time
logger = structlog.get_logger() logger = structlog.get_logger()
router = APIRouter() router = APIRouter(tags=["authentication"])
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
def get_metrics_collector(request: Request): def get_metrics_collector(request: Request):

View File

@@ -21,7 +21,7 @@ from shared.auth.decorators import (
) )
logger = structlog.get_logger() logger = structlog.get_logger()
router = APIRouter() router = APIRouter(tags=["users"])
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
async def get_current_user_info( async def get_current_user_info(
@@ -78,45 +78,3 @@ async def update_current_user(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update user" 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"
)

View File

@@ -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 fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
import uuid from uuid import UUID
from datetime import datetime from datetime import datetime
import base64 import base64
import structlog import structlog
@@ -31,22 +31,23 @@ from app.services.messaging import (
) )
# Import unified authentication from shared library # Import unified authentication from shared library
from shared.auth.decorators import ( from shared.auth.decorators import get_current_user_dep
get_current_user_dep,
get_current_tenant_id_dep
)
router = APIRouter(tags=["sales"]) router = APIRouter(tags=["sales"])
logger = structlog.get_logger() 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( async def create_sales_record(
sales_data: SalesDataCreate, 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Create a new sales record""" """Create a new sales record for tenant"""
try: try:
logger.debug("Creating sales record", logger.debug("Creating sales record",
product=sales_data.product_name, product=sales_data.product_name,
@@ -54,7 +55,7 @@ async def create_sales_record(
tenant_id=tenant_id, tenant_id=tenant_id,
user_id=current_user["user_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 sales_data.tenant_id = tenant_id
record = await SalesService.create_sales_record(sales_data, db) record = await SalesService.create_sales_record(sales_data, db)
@@ -85,14 +86,14 @@ async def create_sales_record(
tenant_id=tenant_id) tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to create sales record: {str(e)}") 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( async def create_bulk_sales(
sales_data: List[SalesDataCreate], 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Create multiple sales records""" """Create multiple sales records for tenant"""
try: try:
logger.debug("Creating bulk sales records", logger.debug("Creating bulk sales records",
count=len(sales_data), count=len(sales_data),
@@ -127,16 +128,16 @@ async def create_bulk_sales(
tenant_id=tenant_id) tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to create bulk sales records: {str(e)}") 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( async def get_sales_data(
start_date: Optional[datetime] = Query(None), tenant_id: UUID = Path(..., description="Tenant ID"),
end_date: Optional[datetime] = Query(None), start_date: Optional[datetime] = Query(None, description="Start date filter"),
product_name: Optional[str] = Query(None), end_date: Optional[datetime] = Query(None, description="End date filter"),
tenant_id: str = Depends(get_current_tenant_id_dep), product_name: Optional[str] = Query(None, description="Product name filter"),
current_user: Dict[str, Any] = Depends(get_current_user_dep), current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Get sales data with filters""" """Get sales data for tenant with filters"""
try: try:
logger.debug("Querying sales data", logger.debug("Querying sales data",
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -164,15 +165,15 @@ async def get_sales_data(
tenant_id=tenant_id) tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to query sales data: {str(e)}") 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( async def import_sales_data(
tenant_id: UUID = Path(..., description="Tenant ID"),
file: UploadFile = File(...), file: UploadFile = File(...),
file_format: str = Form(...), file_format: str = Form(...),
tenant_id: str = Depends(get_current_tenant_id_dep),
current_user: Dict[str, Any] = Depends(get_current_user_dep), current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Import sales data from file""" """Import sales data from file for tenant"""
try: try:
logger.info("Importing sales data", logger.info("Importing sales data",
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -220,26 +221,27 @@ async def import_sales_data(
tenant_id=tenant_id) tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to import sales data: {str(e)}") 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( async def validate_import_data(
import_data: SalesDataImport, 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) current_user: Dict[str, Any] = Depends(get_current_user_dep)
): ):
"""Validate import data before processing""" """Validate import data - Gateway already verified tenant access"""
try: 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 import_data.tenant_id = tenant_id
validation = await DataImportService.validate_import_data( validation = await DataImportService.validate_import_data(import_data.model_dump())
import_data.model_dump()
)
logger.debug("Validation completed", logger.debug("Validation completed",
is_valid=validation.get("is_valid", False), is_valid=validation.get("is_valid", False),
tenant_id=tenant_id) tenant_id=tenant_id)
return validation return validation
except Exception as e: except Exception as e:
@@ -248,15 +250,17 @@ async def validate_import_data(
tenant_id=tenant_id) tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to validate import data: {str(e)}") 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( 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) current_user: Dict[str, Any] = Depends(get_current_user_dep)
): ):
"""Get import template for specified format""" """Get import template for specified format"""
try: try:
logger.debug("Getting import template", logger.debug("Getting import template",
format=format_type, format=format_type,
tenant_id=tenant_id,
user_id=current_user["user_id"]) user_id=current_user["user_id"])
template = await DataImportService.get_import_template(format_type) 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"]) logger.warning("Template generation error", error=template["error"])
raise HTTPException(status_code=400, detail=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": if format_type.lower() == "csv":
return Response( return Response(
@@ -291,14 +297,16 @@ async def get_import_template(
except HTTPException: except HTTPException:
raise raise
except Exception as e: 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)}") 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( async def get_sales_analytics(
tenant_id: UUID = Path(..., description="Tenant ID"),
start_date: Optional[datetime] = Query(None, description="Start date"), start_date: Optional[datetime] = Query(None, description="Start date"),
end_date: Optional[datetime] = Query(None, description="End 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
@@ -322,17 +330,17 @@ async def get_sales_analytics(
tenant_id=tenant_id) tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to generate analytics: {str(e)}") 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( async def export_sales_data(
tenant_id: UUID = Path(..., description="Tenant ID"),
export_format: str = Query("csv", description="Export format: csv, excel, json"), export_format: str = Query("csv", description="Export format: csv, excel, json"),
start_date: Optional[datetime] = Query(None, description="Start date"), start_date: Optional[datetime] = Query(None, description="Start date"),
end_date: Optional[datetime] = Query(None, description="End date"), end_date: Optional[datetime] = Query(None, description="End date"),
products: Optional[List[str]] = Query(None, description="Filter by products"), 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Export sales data in specified format""" """Export sales data in specified format for tenant"""
try: try:
logger.info("Exporting sales data", logger.info("Exporting sales data",
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -376,14 +384,14 @@ async def export_sales_data(
tenant_id=tenant_id) tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to export sales data: {str(e)}") 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( async def delete_sales_record(
record_id: str, tenant_id: UUID = Path(..., description="Tenant ID"),
tenant_id: str = Depends(get_current_tenant_id_dep), record_id: str = Path(..., description="Sales record ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep), current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Delete a sales record""" """Delete a sales record for tenant"""
try: try:
logger.info("Deleting sales record", logger.info("Deleting sales record",
record_id=record_id, record_id=record_id,
@@ -413,14 +421,14 @@ async def delete_sales_record(
tenant_id=tenant_id) tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to delete sales record: {str(e)}") 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( async def get_sales_summary(
tenant_id: UUID = Path(..., description="Tenant ID"),
period: str = Query("daily", description="Summary period: daily, weekly, monthly"), 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Get sales summary for specified period""" """Get sales summary for specified period for tenant"""
try: try:
logger.debug("Getting sales summary", logger.debug("Getting sales summary",
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -437,13 +445,13 @@ async def get_sales_summary(
tenant_id=tenant_id) tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to generate summary: {str(e)}") 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( 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Get list of all products with sales data""" """Get list of all products with sales data for tenant"""
try: try:
logger.debug("Getting products list", tenant_id=tenant_id) logger.debug("Getting products list", tenant_id=tenant_id)
@@ -459,3 +467,77 @@ async def get_products_list(
error=str(e), error=str(e),
tenant_id=tenant_id) tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get products list: {str(e)}") 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)}")

View File

@@ -3,11 +3,12 @@
# ================================================================ # ================================================================
"""Traffic data API endpoints with improved error handling""" """Traffic data API endpoints with improved error handling"""
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, Path
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Dict, Any from typing import List, Dict, Any
from datetime import datetime, timedelta from datetime import datetime, timedelta
import structlog import structlog
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.services.traffic_service import TrafficService from app.services.traffic_service import TrafficService
@@ -23,14 +24,15 @@ from shared.auth.decorators import (
get_current_tenant_id_dep get_current_tenant_id_dep
) )
router = APIRouter() router = APIRouter(tags=["traffic"])
traffic_service = TrafficService() traffic_service = TrafficService()
logger = structlog.get_logger() logger = structlog.get_logger()
@router.get("/current", response_model=TrafficDataResponse) @router.get("/tenants/{tenant_id}/current", response_model=TrafficDataResponse)
async def get_current_traffic( async def get_current_traffic(
latitude: float = Query(..., description="Latitude"), latitude: float = Query(..., description="Latitude"),
longitude: float = Query(..., description="Longitude"), longitude: float = Query(..., description="Longitude"),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep), current_user: Dict[str, Any] = Depends(get_current_user_dep),
): ):
"""Get current traffic data for location""" """Get current traffic data for location"""
@@ -69,13 +71,14 @@ async def get_current_traffic(
logger.error("Traffic API traceback", traceback=traceback.format_exc()) logger.error("Traffic API traceback", traceback=traceback.format_exc())
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") 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( async def get_historical_traffic(
latitude: float = Query(..., description="Latitude"), latitude: float = Query(..., description="Latitude"),
longitude: float = Query(..., description="Longitude"), longitude: float = Query(..., description="Longitude"),
start_date: datetime = Query(..., description="Start date"), start_date: datetime = Query(..., description="Start date"),
end_date: datetime = Query(..., description="End date"), end_date: datetime = Query(..., description="End date"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep), current_user: Dict[str, Any] = Depends(get_current_user_dep),
): ):
"""Get historical traffic data""" """Get historical traffic data"""
@@ -115,11 +118,12 @@ async def get_historical_traffic(
logger.error("Unexpected error in historical traffic API", error=str(e)) logger.error("Unexpected error in historical traffic API", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server 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( async def store_traffic_data(
latitude: float = Query(..., description="Latitude"), latitude: float = Query(..., description="Latitude"),
longitude: float = Query(..., description="Longitude"), longitude: float = Query(..., description="Longitude"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep) current_user: Dict[str, Any] = Depends(get_current_user_dep)
): ):
"""Store current traffic data to database""" """Store current traffic data to database"""

View File

@@ -1,10 +1,11 @@
# services/data/app/api/weather.py - UPDATED WITH UNIFIED AUTH # services/data/app/api/weather.py - UPDATED WITH UNIFIED AUTH
"""Weather data API endpoints with unified authentication""" """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 typing import List, Optional, Dict, Any
from datetime import datetime, date from datetime import datetime, date
import structlog import structlog
from uuid import UUID
from app.schemas.external import ( from app.schemas.external import (
WeatherDataResponse, WeatherDataResponse,
@@ -19,14 +20,14 @@ from shared.auth.decorators import (
get_current_tenant_id_dep get_current_tenant_id_dep
) )
router = APIRouter(prefix="/weather", tags=["weather"]) router = APIRouter(tags=["weather"])
logger = structlog.get_logger() logger = structlog.get_logger()
@router.get("/current", response_model=WeatherDataResponse) @router.get("/tenants/{tenant_id}/current", response_model=WeatherDataResponse)
async def get_current_weather( async def get_current_weather(
latitude: float = Query(..., description="Latitude"), latitude: float = Query(..., description="Latitude"),
longitude: float = Query(..., description="Longitude"), 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
): ):
"""Get current weather data for location""" """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)) logger.error("Failed to get current weather", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server 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( async def get_weather_forecast(
latitude: float = Query(..., description="Latitude"), latitude: float = Query(..., description="Latitude"),
longitude: float = Query(..., description="Longitude"), longitude: float = Query(..., description="Longitude"),
days: int = Query(7, description="Number of forecast days", ge=1, le=14), 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
): ):
"""Get weather forecast for location""" """Get weather forecast for location"""
@@ -108,13 +109,13 @@ async def get_weather_forecast(
logger.error("Failed to get weather forecast", error=str(e)) logger.error("Failed to get weather forecast", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server 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( async def get_weather_history(
start_date: date = Query(..., description="Start date"), start_date: date = Query(..., description="Start date"),
end_date: date = Query(..., description="End date"), end_date: date = Query(..., description="End date"),
latitude: float = Query(..., description="Latitude"), latitude: float = Query(..., description="Latitude"),
longitude: float = Query(..., description="Longitude"), 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""" """Get historical weather data"""
try: try:
@@ -134,11 +135,11 @@ async def get_weather_history(
logger.error("Failed to get weather history", error=str(e)) logger.error("Failed to get weather history", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server 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( async def sync_weather_data(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
force: bool = Query(False, description="Force sync even if recently synced"), 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
): ):
"""Manually trigger weather data synchronization""" """Manually trigger weather data synchronization"""

View File

@@ -108,9 +108,9 @@ app.add_middleware(
) )
# Include routers # Include routers
app.include_router(sales_router, prefix="/api/v1/sales", tags=["sales"]) app.include_router(sales_router, prefix="/api/v1", tags=["sales"])
app.include_router(weather_router, prefix="/api/v1/weather", tags=["weather"]) app.include_router(weather_router, prefix="/api/v1", tags=["weather"])
app.include_router(traffic_router, prefix="/api/v1/traffic", tags=["traffic"]) app.include_router(traffic_router, prefix="/api/v1", tags=["traffic"])
# Health check endpoint # Health check endpoint
@app.get("/health") @app.get("/health")

View File

@@ -3,10 +3,11 @@
Tenant API endpoints 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 sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Dict, Any from typing import List, Dict, Any
import structlog import structlog
from uuid import UUID
from app.core.database import get_db from app.core.database import get_db
from app.schemas.tenants import ( from app.schemas.tenants import (
@@ -45,8 +46,8 @@ async def register_bakery(
@router.get("/tenants/{tenant_id}/access/{user_id}", response_model=TenantAccessResponse) @router.get("/tenants/{tenant_id}/access/{user_id}", response_model=TenantAccessResponse)
async def verify_tenant_access( async def verify_tenant_access(
tenant_id: str,
user_id: str, user_id: str,
tenant_id: UUID = Path(..., description="Tenant ID"),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Verify if user has access to tenant - Called by Gateway""" """Verify if user has access to tenant - Called by Gateway"""
@@ -62,7 +63,7 @@ async def verify_tenant_access(
detail="Access verification failed" 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( async def get_user_tenants(
user_id: str, user_id: str,
current_user: Dict[str, Any] = Depends(get_current_user_dep), 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) @router.get("/tenants/{tenant_id}", response_model=TenantResponse)
async def get_tenant( async def get_tenant(
tenant_id: str, tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep), current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
@@ -113,8 +114,8 @@ async def get_tenant(
@router.put("/tenants/{tenant_id}", response_model=TenantResponse) @router.put("/tenants/{tenant_id}", response_model=TenantResponse)
async def update_tenant( async def update_tenant(
tenant_id: str,
update_data: TenantUpdate, update_data: TenantUpdate,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep), current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
@@ -134,9 +135,9 @@ async def update_tenant(
@router.post("/tenants/{tenant_id}/members", response_model=TenantMemberResponse) @router.post("/tenants/{tenant_id}/members", response_model=TenantMemberResponse)
async def add_team_member( async def add_team_member(
tenant_id: str,
user_id: str, user_id: str,
role: str, role: str,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep), current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):

View File

@@ -89,7 +89,7 @@ async def health_check():
@app.get("/metrics") @app.get("/metrics")
async def metrics(): async def metrics():
"""Prometheus metrics endpoint""" """Prometheus metrics endpoint"""
return metrics_collector.generate_latest() return metrics_collector.get_metrics()
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn

View File

@@ -3,11 +3,11 @@
# ================================================================ # ================================================================
"""Training API endpoints with unified authentication""" """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 typing import List, Optional, Dict, Any
from datetime import datetime from datetime import datetime
import structlog import structlog
import uuid from uuid import UUID, uuid4
from app.schemas.training import ( from app.schemas.training import (
TrainingJobRequest, TrainingJobRequest,
@@ -49,7 +49,7 @@ def get_training_service() -> TrainingService:
async def start_training_job( async def start_training_job(
request: TrainingJobRequest, request: TrainingJobRequest,
background_tasks: BackgroundTasks, 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
training_service: TrainingService = Depends(get_training_service), training_service: TrainingService = Depends(get_training_service),
db: AsyncSession = Depends(get_db_session) # Ensure db is available 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""" """Start a new training job for all products"""
try: try:
new_job_id = str(uuid.uuid4()) new_job_id = str(uuid4())
logger.info("Starting training job", logger.info("Starting training job",
tenant_id=tenant_id, tenant_id=tenant_id,
job_id=uuid.uuid4(), job_id=uuid4(),
config=request.dict()) config=request.dict())
# Create training job # Create training job
@@ -115,7 +115,7 @@ async def get_training_jobs(
status: Optional[TrainingStatus] = Query(None, description="Filter jobs by status"), status: Optional[TrainingStatus] = Query(None, description="Filter jobs by status"),
limit: int = Query(100, ge=1, le=1000), limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0), 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
training_service: TrainingService = Depends(get_training_service) training_service: TrainingService = Depends(get_training_service)
): ):
@@ -149,7 +149,7 @@ async def get_training_jobs(
@router.get("/jobs/{job_id}", response_model=TrainingJobResponse) @router.get("/jobs/{job_id}", response_model=TrainingJobResponse)
async def get_training_job( async def get_training_job(
job_id: str, 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
training_service: TrainingService = Depends(get_training_service) 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) @router.get("/jobs/{job_id}/progress", response_model=TrainingJobProgress)
async def get_training_progress( async def get_training_progress(
job_id: str, 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
training_service: TrainingService = Depends(get_training_service) training_service: TrainingService = Depends(get_training_service)
): ):
@@ -212,7 +212,7 @@ async def get_training_progress(
@router.post("/jobs/{job_id}/cancel") @router.post("/jobs/{job_id}/cancel")
async def cancel_training_job( async def cancel_training_job(
job_id: str, 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
training_service: TrainingService = Depends(get_training_service) training_service: TrainingService = Depends(get_training_service)
): ):
@@ -259,7 +259,7 @@ async def train_single_product(
product_name: str, product_name: str,
request: SingleProductTrainingRequest, request: SingleProductTrainingRequest,
background_tasks: BackgroundTasks, 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
training_service: TrainingService = Depends(get_training_service), training_service: TrainingService = Depends(get_training_service),
db: AsyncSession = Depends(get_db_session) db: AsyncSession = Depends(get_db_session)
@@ -312,7 +312,7 @@ async def train_single_product(
@router.post("/validate", response_model=DataValidationResponse) @router.post("/validate", response_model=DataValidationResponse)
async def validate_training_data( async def validate_training_data(
request: DataValidationRequest, 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
training_service: TrainingService = Depends(get_training_service) training_service: TrainingService = Depends(get_training_service)
): ):
@@ -343,7 +343,7 @@ async def validate_training_data(
@router.get("/models") @router.get("/models")
async def get_trained_models( async def get_trained_models(
product_name: Optional[str] = Query(None), 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
training_service: TrainingService = Depends(get_training_service) training_service: TrainingService = Depends(get_training_service)
): ):
@@ -374,7 +374,7 @@ async def get_trained_models(
@require_role("admin") # Only admins can delete models @require_role("admin") # Only admins can delete models
async def delete_model( async def delete_model(
model_id: str, 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
training_service: TrainingService = Depends(get_training_service) training_service: TrainingService = Depends(get_training_service)
): ):
@@ -411,7 +411,7 @@ async def delete_model(
async def get_training_stats( async def get_training_stats(
start_date: Optional[datetime] = Query(None), start_date: Optional[datetime] = Query(None),
end_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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
training_service: TrainingService = Depends(get_training_service) training_service: TrainingService = Depends(get_training_service)
): ):
@@ -442,7 +442,7 @@ async def get_training_stats(
async def retrain_all_products( async def retrain_all_products(
request: TrainingJobRequest, request: TrainingJobRequest,
background_tasks: BackgroundTasks, 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), current_user: Dict[str, Any] = Depends(get_current_user_dep),
training_service: TrainingService = Depends(get_training_service) training_service: TrainingService = Depends(get_training_service)
): ):

View File

@@ -173,18 +173,10 @@ async def global_exception_handler(request: Request, exc: Exception):
} }
) )
# Include API routers - NO AUTH DEPENDENCIES HERE # Include API routers
# Authentication is handled by API Gateway app.include_router(training.router, prefix="/api/v1", tags=["training"])
app.include_router( app.include_router(models.router, prefix="/api/v1", tags=["models"])
training.router,
tags=["training"]
)
app.include_router(
models.router,
prefix="/models",
tags=["models"]
)
# Health check endpoints # Health check endpoints
@app.get("/health") @app.get("/health")

View File

@@ -488,7 +488,7 @@ class TrainingService:
params["end_date"] = request.end_date.isoformat() params["end_date"] = request.end_date.isoformat()
response = await client.get( response = await client.get(
f"{settings.DATA_SERVICE_URL}/weather/history", f"{settings.DATA_SERVICE_URL}/tenants/{tenant_id}/weather/history",
params=params, params=params,
timeout=30.0 timeout=30.0
) )
@@ -516,7 +516,7 @@ class TrainingService:
params["end_date"] = request.end_date.isoformat() params["end_date"] = request.end_date.isoformat()
response = await client.get( response = await client.get(
f"{settings.DATA_SERVICE_URL}/traffic/historical", f"{settings.DATA_SERVICE_URL}/tenants/{tenant_id}/traffic/historical",
params=params, params=params,
timeout=30.0 timeout=30.0
) )

View File

@@ -136,21 +136,21 @@ def get_current_tenant_id(request: Request) -> Optional[str]:
def extract_user_from_headers(request: Request) -> Optional[Dict[str, Any]]: def extract_user_from_headers(request: Request) -> Optional[Dict[str, Any]]:
"""Extract user information from forwarded headers (gateway sets these)""" """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: if not user_id:
return None return None
return { return {
"user_id": user_id, "user_id": user_id,
"email": request.headers.get("X-User-Email", ""), "email": request.headers.get("x-user-email", ""),
"role": request.headers.get("X-User-Role", "user"), "role": request.headers.get("x-user-role", "user"),
"tenant_id": request.headers.get("X-Tenant-ID"), "tenant_id": request.headers.get("x-tenant-id"),
"permissions": request.headers.get("X-User-Permissions", "").split(",") if request.headers.get("X-User-Permissions") else [] "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]: def extract_tenant_from_headers(request: Request) -> Optional[str]:
"""Extract tenant ID from headers""" """Extract tenant ID from headers"""
return request.headers.get("X-Tenant-ID") return request.headers.get("x-tenant-id")
# FastAPI Dependencies for injection # FastAPI Dependencies for injection
async def get_current_user_dep(request: Request) -> Dict[str, Any]: async def get_current_user_dep(request: Request) -> Dict[str, Any]:

View File

@@ -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"
]

117
test_new.sh Executable file
View File

@@ -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!"