Add user delete process

This commit is contained in:
Urtzi Alfaro
2025-10-31 11:54:19 +01:00
parent 63f5c6d512
commit 269d3b5032
74 changed files with 16783 additions and 213 deletions

View File

@@ -336,3 +336,73 @@ analytics_tier_required = require_subscription_tier(['professional', 'enterprise
enterprise_tier_required = require_subscription_tier(['enterprise'])
admin_role_required = require_user_role(['admin', 'owner'])
owner_role_required = require_user_role(['owner'])
def service_only_access(func: Callable) -> Callable:
"""
Decorator to restrict endpoint access to service-to-service calls only
This decorator validates that:
1. The request has a valid service token (type='service' in JWT)
2. The token is from an authorized internal service
Usage:
@router.delete("/tenant/{tenant_id}")
@service_only_access
async def delete_tenant_data(
tenant_id: str,
current_user: dict = Depends(get_current_user_dep),
db = Depends(get_db)
):
# Service-only logic here
The decorator expects current_user to be injected via get_current_user_dep
dependency, which should already contain the user/service context from JWT.
"""
@wraps(func)
async def wrapper(*args, **kwargs):
# Get current user from kwargs (injected by get_current_user_dep)
current_user = kwargs.get('current_user')
if not current_user:
# Try to find in args
for arg in args:
if isinstance(arg, dict) and 'user_id' in arg:
current_user = arg
break
if not current_user:
logger.error("Service-only access: current user not found in request context")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required"
)
# Check if this is a service token
user_type = current_user.get('type', '')
is_service = current_user.get('is_service', False)
if user_type != 'service' and not is_service:
logger.warning(
"Service-only access denied: not a service token",
user_id=current_user.get('user_id'),
user_type=user_type,
is_service=is_service
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This endpoint is only accessible to internal services"
)
# Log successful service access
service_name = current_user.get('service', current_user.get('user_id', 'unknown'))
logger.info(
"Service-only access granted",
service=service_name,
endpoint=func.__name__
)
return await func(*args, **kwargs)
return wrapper

View File

@@ -201,6 +201,43 @@ class JWTHandler:
return None
def create_service_token(self, service_name: str, expires_delta: Optional[timedelta] = None) -> str:
"""
Create JWT token for service-to-service communication
Args:
service_name: Name of the service (e.g., 'auth-service', 'tenant-service')
expires_delta: Optional expiration time (defaults to 365 days for services)
Returns:
Encoded JWT service token
"""
to_encode = {
"sub": service_name,
"user_id": service_name,
"service": service_name,
"type": "service",
"is_service": True,
"role": "admin", # Services have admin privileges
"email": f"{service_name}@internal.service"
}
# Set expiration (default to 1 year for service tokens)
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(days=365)
to_encode.update({
"exp": expire,
"iat": datetime.now(timezone.utc),
"iss": "bakery-auth"
})
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
logger.info(f"Created service token for {service_name}")
return encoded_jwt
def get_token_info(self, token: str) -> Dict[str, Any]:
"""
Get comprehensive token information for debugging
@@ -214,7 +251,7 @@ class JWTHandler:
"exp": None,
"iat": None
}
try:
# Try unsafe decode first
payload = self.decode_token_no_verify(token)
@@ -227,12 +264,12 @@ class JWTHandler:
"iat": payload.get("iat"),
"expired": self.is_token_expired(token)
})
# Try full verification
verified_payload = self.verify_token(token)
info["valid"] = verified_payload is not None
except Exception as e:
logger.warning(f"Failed to get token info: {e}")
return info

View File

@@ -49,6 +49,8 @@ class Invoice:
created_at: datetime
due_date: Optional[datetime] = None
description: Optional[str] = None
invoice_pdf: Optional[str] = None # URL to PDF invoice
hosted_invoice_url: Optional[str] = None # URL to hosted invoice page
class PaymentProvider(abc.ABC):

View File

@@ -151,10 +151,11 @@ class StripeProvider(PaymentProvider):
"""
try:
stripe_invoices = stripe.Invoice.list(customer=customer_id, limit=100)
invoices = []
for stripe_invoice in stripe_invoices:
invoices.append(Invoice(
# Create base invoice object
invoice = Invoice(
id=stripe_invoice.id,
customer_id=stripe_invoice.customer,
subscription_id=stripe_invoice.subscription,
@@ -164,8 +165,14 @@ class StripeProvider(PaymentProvider):
created_at=datetime.fromtimestamp(stripe_invoice.created),
due_date=datetime.fromtimestamp(stripe_invoice.due_date) if stripe_invoice.due_date else None,
description=stripe_invoice.description
))
)
# Add Stripe-specific URLs as custom attributes
invoice.invoice_pdf = stripe_invoice.invoice_pdf if hasattr(stripe_invoice, 'invoice_pdf') else None
invoice.hosted_invoice_url = stripe_invoice.hosted_invoice_url if hasattr(stripe_invoice, 'hosted_invoice_url') else None
invoices.append(invoice)
return invoices
except stripe.error.StripeError as e:
logger.error("Failed to retrieve Stripe invoices", error=str(e))

View File

@@ -236,6 +236,7 @@ class BaseServiceSettings(BaseSettings):
DEMO_SESSION_SERVICE_URL: str = os.getenv("DEMO_SESSION_SERVICE_URL", "http://demo-session-service:8000")
ALERT_PROCESSOR_SERVICE_URL: str = os.getenv("ALERT_PROCESSOR_SERVICE_URL", "http://alert-processor-api:8010")
PROCUREMENT_SERVICE_URL: str = os.getenv("PROCUREMENT_SERVICE_URL", "http://procurement-service:8000")
ORCHESTRATOR_SERVICE_URL: str = os.getenv("ORCHESTRATOR_SERVICE_URL", "http://orchestrator-service:8000")
# HTTP Client Settings
HTTP_TIMEOUT: int = int(os.getenv("HTTP_TIMEOUT", "30"))

View File

@@ -0,0 +1,17 @@
"""
Shared services module
Contains base classes and utilities for common service functionality
"""
from .tenant_deletion import (
BaseTenantDataDeletionService,
TenantDataDeletionResult,
create_tenant_deletion_endpoint_handler,
create_tenant_deletion_preview_handler,
)
__all__ = [
"BaseTenantDataDeletionService",
"TenantDataDeletionResult",
"create_tenant_deletion_endpoint_handler",
"create_tenant_deletion_preview_handler",
]

View File

@@ -0,0 +1,197 @@
"""
Shared tenant deletion utilities
Base classes and utilities for implementing tenant data deletion across services
"""
from typing import Dict, Any, List
from abc import ABC, abstractmethod
import structlog
from datetime import datetime
logger = structlog.get_logger()
class TenantDataDeletionResult:
"""Standard result for tenant data deletion operations"""
def __init__(self, tenant_id: str, service_name: str):
self.tenant_id = tenant_id
self.service_name = service_name
self.deleted_counts: Dict[str, int] = {}
self.errors: List[str] = []
self.timestamp = datetime.utcnow().isoformat()
self.success = True
def add_deleted_items(self, entity_type: str, count: int):
"""Record deleted items for an entity type"""
self.deleted_counts[entity_type] = count
def add_error(self, error: str):
"""Add an error message"""
self.errors.append(error)
self.success = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API response"""
return {
"tenant_id": self.tenant_id,
"service_name": self.service_name,
"deleted_counts": self.deleted_counts,
"total_deleted": sum(self.deleted_counts.values()),
"errors": self.errors,
"success": self.success,
"timestamp": self.timestamp
}
class BaseTenantDataDeletionService(ABC):
"""
Base class for tenant data deletion services
Each microservice should implement this to handle their own data cleanup
"""
def __init__(self, service_name: str):
self.service_name = service_name
self.logger = structlog.get_logger().bind(service=service_name)
@abstractmethod
async def delete_tenant_data(self, tenant_id: str) -> TenantDataDeletionResult:
"""
Delete all data associated with a tenant
Args:
tenant_id: The tenant whose data should be deleted
Returns:
TenantDataDeletionResult with deletion summary
"""
pass
@abstractmethod
async def get_tenant_data_preview(self, tenant_id: str) -> Dict[str, int]:
"""
Get a preview of what would be deleted (counts only)
Args:
tenant_id: The tenant to preview
Returns:
Dict mapping entity types to counts
"""
pass
async def safe_delete_tenant_data(self, tenant_id: str) -> TenantDataDeletionResult:
"""
Safely delete tenant data with error handling
Args:
tenant_id: The tenant whose data should be deleted
Returns:
TenantDataDeletionResult with deletion summary
"""
result = TenantDataDeletionResult(tenant_id, self.service_name)
try:
self.logger.info("Starting tenant data deletion",
tenant_id=tenant_id,
service=self.service_name)
# Call the implementation-specific deletion
result = await self.delete_tenant_data(tenant_id)
self.logger.info("Tenant data deletion completed",
tenant_id=tenant_id,
service=self.service_name,
deleted_counts=result.deleted_counts,
total_deleted=sum(result.deleted_counts.values()),
errors=len(result.errors))
return result
except Exception as e:
self.logger.error("Tenant data deletion failed",
tenant_id=tenant_id,
service=self.service_name,
error=str(e))
result.add_error(f"Fatal error: {str(e)}")
return result
def create_tenant_deletion_endpoint_handler(deletion_service: BaseTenantDataDeletionService):
"""
Factory function to create a FastAPI endpoint handler for tenant deletion
Usage in service API file:
```python
from shared.services.tenant_deletion import create_tenant_deletion_endpoint_handler
deletion_service = MyServiceTenantDeletionService()
delete_tenant_data = create_tenant_deletion_endpoint_handler(deletion_service)
@router.delete("/tenant/{tenant_id}")
async def delete_tenant_data_endpoint(tenant_id: str, current_user: dict = Depends(get_current_user)):
return await delete_tenant_data(tenant_id, current_user)
```
"""
async def handler(tenant_id: str, current_user: Dict[str, Any]) -> Dict[str, Any]:
"""Handle tenant data deletion request"""
# Only allow internal service calls
if current_user.get("type") != "service":
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This endpoint is only accessible to internal services"
)
# Perform deletion
result = await deletion_service.safe_delete_tenant_data(tenant_id)
return {
"message": f"Tenant data deletion completed in {deletion_service.service_name}",
"summary": result.to_dict()
}
return handler
def create_tenant_deletion_preview_handler(deletion_service: BaseTenantDataDeletionService):
"""
Factory function to create a FastAPI endpoint handler for deletion preview
Usage in service API file:
```python
preview_handler = create_tenant_deletion_preview_handler(deletion_service)
@router.get("/tenant/{tenant_id}/deletion-preview")
async def preview_endpoint(tenant_id: str, current_user: dict = Depends(get_current_user)):
return await preview_handler(tenant_id, current_user)
```
"""
async def handler(tenant_id: str, current_user: Dict[str, Any]) -> Dict[str, Any]:
"""Handle deletion preview request"""
# Allow internal services and admins
is_service = current_user.get("type") == "service"
is_admin = current_user.get("role") in ["owner", "admin"]
if not (is_service or is_admin):
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions"
)
# Get preview
preview = await deletion_service.get_tenant_data_preview(tenant_id)
return {
"tenant_id": tenant_id,
"service": deletion_service.service_name,
"data_counts": preview,
"total_items": sum(preview.values())
}
return handler