Add user delete process
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
17
shared/services/__init__.py
Normal file
17
shared/services/__init__.py
Normal 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",
|
||||
]
|
||||
197
shared/services/tenant_deletion.py
Normal file
197
shared/services/tenant_deletion.py
Normal 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
|
||||
Reference in New Issue
Block a user