This commit is contained in:
Iliyan Angelov
2025-12-01 01:08:39 +02:00
parent 0fa2adeb19
commit 1a103a769f
234 changed files with 5513 additions and 283 deletions

View File

@@ -0,0 +1,4 @@
"""
Integration module for webhooks and API keys.
"""

View File

@@ -0,0 +1,48 @@
"""
API key models for third-party access.
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Boolean, JSON
from sqlalchemy.orm import relationship
from datetime import datetime, timedelta
from ...shared.config.database import Base
class APIKey(Base):
__tablename__ = 'api_keys'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
name = Column(String(255), nullable=False)
key_hash = Column(String(255), unique=True, nullable=False, index=True) # Hashed API key
key_prefix = Column(String(20), nullable=False, index=True) # First 8 chars for identification
# Permissions
scopes = Column(JSON, nullable=False) # List of allowed scopes/permissions
rate_limit = Column(Integer, default=100, nullable=False) # Requests per minute
# Status
is_active = Column(Boolean, default=True, nullable=False, index=True)
# Expiration
expires_at = Column(DateTime, nullable=True, index=True)
# Metadata
description = Column(Text, nullable=True)
last_used_at = Column(DateTime, nullable=True)
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
creator = relationship('User', foreign_keys=[created_by])
@property
def is_expired(self) -> bool:
"""Check if API key is expired."""
if not self.expires_at:
return False
return datetime.utcnow() > self.expires_at
@property
def is_valid(self) -> bool:
"""Check if API key is valid (active and not expired)."""
return self.is_active and not self.is_expired

View File

@@ -0,0 +1,84 @@
"""
Webhook models for external integrations.
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Boolean, Enum, JSON
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from ...shared.config.database import Base
class WebhookEventType(str, enum.Enum):
booking_created = "booking.created"
booking_updated = "booking.updated"
booking_cancelled = "booking.cancelled"
payment_completed = "payment.completed"
payment_failed = "payment.failed"
invoice_created = "invoice.created"
invoice_paid = "invoice.paid"
user_created = "user.created"
user_updated = "user.updated"
class WebhookStatus(str, enum.Enum):
active = "active"
inactive = "inactive"
paused = "paused"
class WebhookDeliveryStatus(str, enum.Enum):
pending = "pending"
success = "success"
failed = "failed"
retrying = "retrying"
class Webhook(Base):
__tablename__ = 'webhooks'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
name = Column(String(255), nullable=False)
url = Column(String(500), nullable=False)
secret = Column(String(255), nullable=False) # For signature verification
# Event subscriptions
events = Column(JSON, nullable=False) # List of event types
# Status
status = Column(Enum(WebhookStatus), default=WebhookStatus.active, nullable=False, index=True)
# Configuration
retry_count = Column(Integer, default=3, nullable=False)
timeout_seconds = Column(Integer, default=30, nullable=False)
# Metadata
description = Column(Text, nullable=True)
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
creator = relationship('User', foreign_keys=[created_by])
class WebhookDelivery(Base):
__tablename__ = 'webhook_deliveries'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
webhook_id = Column(Integer, ForeignKey('webhooks.id'), nullable=False, index=True)
event_type = Column(String(100), nullable=False, index=True)
event_id = Column(String(255), nullable=False, index=True)
# Delivery details
status = Column(Enum(WebhookDeliveryStatus), default=WebhookDeliveryStatus.pending, nullable=False, index=True)
payload = Column(JSON, nullable=False)
response_status = Column(Integer, nullable=True)
response_body = Column(Text, nullable=True)
error_message = Column(Text, nullable=True)
# Retry information
attempt_count = Column(Integer, default=0, nullable=False)
next_retry_at = Column(DateTime, nullable=True)
# Timestamps
delivered_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
# Relationships
webhook = relationship('Webhook', foreign_keys=[webhook_id])

View File

@@ -0,0 +1,4 @@
"""
Integration routes for webhooks and API keys.
"""

View File

@@ -0,0 +1,165 @@
"""
API key management routes.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import get_current_user, authorize_roles
from ...auth.models.user import User
from ..services.api_key_service import api_key_service
from ..models.api_key import APIKey
from ...shared.utils.response_helpers import success_response
from pydantic import BaseModel
logger = get_logger(__name__)
router = APIRouter(prefix='/api-keys', tags=['api-keys'])
class CreateAPIKeyRequest(BaseModel):
name: str
scopes: List[str]
description: Optional[str] = None
rate_limit: int = 100
expires_at: Optional[str] = None
class UpdateAPIKeyRequest(BaseModel):
name: Optional[str] = None
scopes: Optional[List[str]] = None
description: Optional[str] = None
rate_limit: Optional[int] = None
expires_at: Optional[str] = None
@router.post('/')
async def create_api_key(
key_data: CreateAPIKeyRequest,
current_user: User = Depends(authorize_roles('admin')),
db: Session = Depends(get_db)
):
"""Create a new API key."""
try:
expires_at = None
if key_data.expires_at:
expires_at = datetime.fromisoformat(key_data.expires_at.replace('Z', '+00:00'))
api_key, plain_key = api_key_service.create_api_key(
db=db,
name=key_data.name,
scopes=key_data.scopes,
created_by=current_user.id,
description=key_data.description,
rate_limit=key_data.rate_limit,
expires_at=expires_at
)
return success_response(
data={
'api_key': {
'id': api_key.id,
'name': api_key.name,
'key_prefix': api_key.key_prefix,
'scopes': api_key.scopes,
'rate_limit': api_key.rate_limit,
'expires_at': api_key.expires_at.isoformat() if api_key.expires_at else None
},
'key': plain_key # Return plain key only on creation
},
message='API key created successfully. Save this key securely - it will not be shown again.'
)
except Exception as e:
logger.error(f'Error creating API key: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get('/')
async def get_api_keys(
current_user: User = Depends(authorize_roles('admin')),
db: Session = Depends(get_db)
):
"""Get all API keys."""
try:
api_keys = db.query(APIKey).order_by(APIKey.created_at.desc()).all()
return success_response(data={
'api_keys': [{
'id': k.id,
'name': k.name,
'key_prefix': k.key_prefix,
'scopes': k.scopes,
'rate_limit': k.rate_limit,
'is_active': k.is_active,
'last_used_at': k.last_used_at.isoformat() if k.last_used_at else None,
'expires_at': k.expires_at.isoformat() if k.expires_at else None,
'created_at': k.created_at.isoformat() if k.created_at else None
} for k in api_keys]
})
except Exception as e:
logger.error(f'Error getting API keys: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put('/{key_id}')
async def update_api_key(
key_id: int,
key_data: UpdateAPIKeyRequest,
current_user: User = Depends(authorize_roles('admin')),
db: Session = Depends(get_db)
):
"""Update an API key."""
try:
expires_at = None
if key_data.expires_at:
expires_at = datetime.fromisoformat(key_data.expires_at.replace('Z', '+00:00'))
api_key = api_key_service.update_api_key(
db=db,
key_id=key_id,
name=key_data.name,
scopes=key_data.scopes,
description=key_data.description,
rate_limit=key_data.rate_limit,
expires_at=expires_at
)
if not api_key:
raise HTTPException(status_code=404, detail='API key not found')
return success_response(
data={
'api_key': {
'id': api_key.id,
'name': api_key.name,
'key_prefix': api_key.key_prefix,
'scopes': api_key.scopes,
'rate_limit': api_key.rate_limit,
'is_active': api_key.is_active,
'expires_at': api_key.expires_at.isoformat() if api_key.expires_at else None,
'created_at': api_key.created_at.isoformat() if api_key.created_at else None
}
},
message='API key updated successfully'
)
except HTTPException:
raise
except Exception as e:
logger.error(f'Error updating API key: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.delete('/{key_id}')
async def revoke_api_key(
key_id: int,
current_user: User = Depends(authorize_roles('admin')),
db: Session = Depends(get_db)
):
"""Revoke an API key."""
try:
success = api_key_service.revoke_api_key(db=db, key_id=key_id)
if not success:
raise HTTPException(status_code=404, detail='API key not found')
return success_response(message='API key revoked successfully')
except HTTPException:
raise
except Exception as e:
logger.error(f'Error revoking API key: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,199 @@
"""
Webhook management routes.
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import get_current_user, authorize_roles
from ...auth.models.user import User
from ..services.webhook_service import webhook_service
from ..models.webhook import Webhook, WebhookDelivery, WebhookEventType, WebhookStatus
from ...shared.utils.response_helpers import success_response
from pydantic import BaseModel, HttpUrl
logger = get_logger(__name__)
router = APIRouter(prefix='/webhooks', tags=['webhooks'])
class CreateWebhookRequest(BaseModel):
name: str
url: str
events: List[str]
description: Optional[str] = None
retry_count: int = 3
timeout_seconds: int = 30
@router.post('/')
async def create_webhook(
webhook_data: CreateWebhookRequest,
current_user: User = Depends(authorize_roles('admin')),
db: Session = Depends(get_db)
):
"""Create a new webhook."""
try:
webhook = webhook_service.create_webhook(
db=db,
name=webhook_data.name,
url=webhook_data.url,
events=webhook_data.events,
created_by=current_user.id,
description=webhook_data.description,
retry_count=webhook_data.retry_count,
timeout_seconds=webhook_data.timeout_seconds
)
return success_response(
data={'webhook': {
'id': webhook.id,
'name': webhook.name,
'url': webhook.url,
'events': webhook.events,
'status': webhook.status.value,
'secret': webhook.secret # Return secret only on creation
}},
message='Webhook created successfully'
)
except Exception as e:
logger.error(f'Error creating webhook: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get('/')
async def get_webhooks(
current_user: User = Depends(authorize_roles('admin')),
db: Session = Depends(get_db)
):
"""Get all webhooks."""
try:
webhooks = db.query(Webhook).order_by(Webhook.created_at.desc()).all()
return success_response(data={
'webhooks': [{
'id': w.id,
'name': w.name,
'url': w.url,
'events': w.events,
'status': w.status.value,
'created_at': w.created_at.isoformat() if w.created_at else None
} for w in webhooks]
})
except Exception as e:
logger.error(f'Error getting webhooks: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get('/{webhook_id}/deliveries')
async def get_webhook_deliveries(
webhook_id: int,
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=100),
current_user: User = Depends(authorize_roles('admin')),
db: Session = Depends(get_db)
):
"""Get webhook delivery history."""
try:
offset = (page - 1) * limit
deliveries = db.query(WebhookDelivery).filter(
WebhookDelivery.webhook_id == webhook_id
).order_by(WebhookDelivery.created_at.desc()).offset(offset).limit(limit).all()
total = db.query(WebhookDelivery).filter(
WebhookDelivery.webhook_id == webhook_id
).count()
return success_response(data={
'deliveries': [{
'id': d.id,
'event_type': d.event_type,
'status': d.status.value,
'response_status': d.response_status,
'attempt_count': d.attempt_count,
'created_at': d.created_at.isoformat() if d.created_at else None,
'delivered_at': d.delivered_at.isoformat() if d.delivered_at else None
} for d in deliveries],
'pagination': {
'page': page,
'limit': limit,
'total': total,
'total_pages': (total + limit - 1) // limit
}
})
except Exception as e:
logger.error(f'Error getting webhook deliveries: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
class UpdateWebhookRequest(BaseModel):
name: Optional[str] = None
url: Optional[str] = None
events: Optional[List[str]] = None
description: Optional[str] = None
status: Optional[str] = None
retry_count: Optional[int] = None
timeout_seconds: Optional[int] = None
@router.put('/{webhook_id}')
async def update_webhook(
webhook_id: int,
webhook_data: UpdateWebhookRequest,
current_user: User = Depends(authorize_roles('admin')),
db: Session = Depends(get_db)
):
"""Update a webhook."""
try:
status_enum = None
if webhook_data.status:
try:
status_enum = WebhookStatus(webhook_data.status)
except ValueError:
raise HTTPException(status_code=400, detail=f'Invalid status: {webhook_data.status}')
webhook = webhook_service.update_webhook(
db=db,
webhook_id=webhook_id,
name=webhook_data.name,
url=webhook_data.url,
events=webhook_data.events,
description=webhook_data.description,
status=status_enum,
retry_count=webhook_data.retry_count,
timeout_seconds=webhook_data.timeout_seconds
)
if not webhook:
raise HTTPException(status_code=404, detail='Webhook not found')
return success_response(
data={'webhook': {
'id': webhook.id,
'name': webhook.name,
'url': webhook.url,
'events': webhook.events,
'status': webhook.status.value,
'created_at': webhook.created_at.isoformat() if webhook.created_at else None
}},
message='Webhook updated successfully'
)
except HTTPException:
raise
except Exception as e:
logger.error(f'Error updating webhook: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.delete('/{webhook_id}')
async def delete_webhook(
webhook_id: int,
current_user: User = Depends(authorize_roles('admin')),
db: Session = Depends(get_db)
):
"""Delete a webhook."""
try:
success = webhook_service.delete_webhook(db=db, webhook_id=webhook_id)
if not success:
raise HTTPException(status_code=404, detail='Webhook not found')
return success_response(message='Webhook deleted successfully')
except HTTPException:
raise
except Exception as e:
logger.error(f'Error deleting webhook: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,133 @@
"""
API key service for third-party access management.
"""
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime
import secrets
import hashlib
from ..models.api_key import APIKey
from ...shared.config.logging_config import get_logger
logger = get_logger(__name__)
class APIKeyService:
"""Service for managing API keys."""
@staticmethod
def generate_api_key() -> tuple[str, str]:
"""Generate a new API key and return (key, hash)."""
# Generate key in format: hb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
key = f"hb_{secrets.token_urlsafe(32)}"
key_hash = hashlib.sha256(key.encode('utf-8')).hexdigest()
key_prefix = key[:11] # "hb_" + 8 chars
return key, key_hash, key_prefix
@staticmethod
def create_api_key(
db: Session,
name: str,
scopes: List[str],
created_by: int,
description: Optional[str] = None,
rate_limit: int = 100,
expires_at: Optional[datetime] = None
) -> tuple[APIKey, str]:
"""Create a new API key and return (api_key_model, plain_key)."""
key, key_hash, key_prefix = APIKeyService.generate_api_key()
api_key = APIKey(
name=name,
key_hash=key_hash,
key_prefix=key_prefix,
scopes=scopes,
rate_limit=rate_limit,
created_by=created_by,
description=description,
expires_at=expires_at
)
db.add(api_key)
db.commit()
db.refresh(api_key)
logger.info(f'API key created: {api_key.id} - {name}')
return api_key, key # Return plain key only once
@staticmethod
def verify_api_key(db: Session, api_key: str) -> Optional[APIKey]:
"""Verify an API key and return the APIKey model if valid."""
key_hash = hashlib.sha256(api_key.encode('utf-8')).hexdigest()
api_key_model = db.query(APIKey).filter(
APIKey.key_hash == key_hash,
APIKey.is_active == True
).first()
if api_key_model and api_key_model.is_valid:
# Update last used timestamp
api_key_model.last_used_at = datetime.utcnow()
db.commit()
return api_key_model
return None
@staticmethod
def revoke_api_key(db: Session, key_id: int) -> bool:
"""Revoke an API key."""
api_key = db.query(APIKey).filter(APIKey.id == key_id).first()
if api_key:
api_key.is_active = False
db.commit()
logger.info(f'API key {key_id} revoked')
return True
return False
@staticmethod
def get_user_api_keys(
db: Session,
created_by: int,
active_only: bool = True
) -> List[APIKey]:
"""Get all API keys created by a user."""
query = db.query(APIKey).filter(APIKey.created_by == created_by)
if active_only:
query = query.filter(APIKey.is_active == True)
return query.order_by(APIKey.created_at.desc()).all()
@staticmethod
def update_api_key(
db: Session,
key_id: int,
name: Optional[str] = None,
scopes: Optional[List[str]] = None,
description: Optional[str] = None,
rate_limit: Optional[int] = None,
expires_at: Optional[datetime] = None
) -> Optional[APIKey]:
"""Update an API key."""
api_key = db.query(APIKey).filter(APIKey.id == key_id).first()
if not api_key:
return None
if name is not None:
api_key.name = name
if scopes is not None:
api_key.scopes = scopes
if description is not None:
api_key.description = description
if rate_limit is not None:
api_key.rate_limit = rate_limit
if expires_at is not None:
api_key.expires_at = expires_at
db.commit()
db.refresh(api_key)
logger.info(f'API key updated: {api_key.id} - {api_key.name}')
return api_key
api_key_service = APIKeyService()

View File

@@ -0,0 +1,218 @@
"""
Webhook service for external integrations.
"""
from sqlalchemy.orm import Session
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta
import secrets
import httpx
import hmac
import hashlib
import json
from ..models.webhook import Webhook, WebhookDelivery, WebhookEventType, WebhookStatus, WebhookDeliveryStatus
from ...shared.config.logging_config import get_logger
from ...analytics.services.audit_service import audit_service
logger = get_logger(__name__)
class WebhookService:
"""Service for managing webhooks."""
@staticmethod
def create_webhook(
db: Session,
name: str,
url: str,
events: List[str],
created_by: int,
description: Optional[str] = None,
retry_count: int = 3,
timeout_seconds: int = 30
) -> Webhook:
"""Create a new webhook."""
secret = secrets.token_urlsafe(32)
webhook = Webhook(
name=name,
url=url,
secret=secret,
events=events,
created_by=created_by,
description=description,
retry_count=retry_count,
timeout_seconds=timeout_seconds
)
db.add(webhook)
db.commit()
db.refresh(webhook)
logger.info(f'Webhook created: {webhook.id} - {name}')
return webhook
@staticmethod
def update_webhook(
db: Session,
webhook_id: int,
name: Optional[str] = None,
url: Optional[str] = None,
events: Optional[List[str]] = None,
description: Optional[str] = None,
status: Optional[WebhookStatus] = None,
retry_count: Optional[int] = None,
timeout_seconds: Optional[int] = None
) -> Optional[Webhook]:
"""Update a webhook."""
webhook = db.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
return None
if name is not None:
webhook.name = name
if url is not None:
webhook.url = url
if events is not None:
webhook.events = events
if description is not None:
webhook.description = description
if status is not None:
webhook.status = status
if retry_count is not None:
webhook.retry_count = retry_count
if timeout_seconds is not None:
webhook.timeout_seconds = timeout_seconds
db.commit()
db.refresh(webhook)
logger.info(f'Webhook updated: {webhook.id} - {webhook.name}')
return webhook
@staticmethod
def delete_webhook(db: Session, webhook_id: int) -> bool:
"""Delete a webhook."""
webhook = db.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
return False
db.delete(webhook)
db.commit()
logger.info(f'Webhook deleted: {webhook_id}')
return True
@staticmethod
def generate_signature(payload: str, secret: str) -> str:
"""Generate HMAC signature for webhook payload."""
return hmac.new(
secret.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
@staticmethod
async def deliver_webhook(
db: Session,
event_type: str,
event_id: str,
payload: Dict[str, Any]
) -> List[WebhookDelivery]:
"""Deliver webhook event to all subscribed webhooks."""
# Find active webhooks subscribed to this event
webhooks = db.query(Webhook).filter(
Webhook.status == WebhookStatus.active,
Webhook.events.contains([event_type])
).all()
deliveries = []
for webhook in webhooks:
delivery = WebhookDelivery(
webhook_id=webhook.id,
event_type=event_type,
event_id=event_id,
payload=payload,
status=WebhookDeliveryStatus.pending
)
db.add(delivery)
db.commit()
db.refresh(delivery)
# Deliver asynchronously
try:
await WebhookService._send_webhook(webhook, delivery, payload)
except Exception as e:
logger.error(f'Error delivering webhook {delivery.id}: {str(e)}')
deliveries.append(delivery)
return deliveries
@staticmethod
async def _send_webhook(
webhook: Webhook,
delivery: WebhookDelivery,
payload: Dict[str, Any]
):
"""Send webhook HTTP request."""
payload_str = json.dumps(payload)
signature = WebhookService.generate_signature(payload_str, webhook.secret)
headers = {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
'X-Webhook-Event': delivery.event_type,
'X-Webhook-Id': str(delivery.id)
}
try:
async with httpx.AsyncClient(timeout=webhook.timeout_seconds) as client:
response = await client.post(
webhook.url,
content=payload_str,
headers=headers
)
delivery.response_status = response.status_code
delivery.response_body = response.text[:1000] # Limit response body size
delivery.attempt_count += 1
if response.status_code >= 200 and response.status_code < 300:
delivery.status = WebhookDeliveryStatus.success
delivery.delivered_at = datetime.utcnow()
else:
delivery.status = WebhookDeliveryStatus.failed
delivery.error_message = f'HTTP {response.status_code}'
# Schedule retry if attempts remaining
if delivery.attempt_count < webhook.retry_count:
delivery.status = WebhookDeliveryStatus.retrying
delivery.next_retry_at = datetime.utcnow() + timedelta(
minutes=2 ** delivery.attempt_count # Exponential backoff
)
except Exception as e:
delivery.status = WebhookDeliveryStatus.failed
delivery.error_message = str(e)[:500]
delivery.attempt_count += 1
# Schedule retry if attempts remaining
if delivery.attempt_count < webhook.retry_count:
delivery.status = WebhookDeliveryStatus.retrying
delivery.next_retry_at = datetime.utcnow() + timedelta(
minutes=2 ** delivery.attempt_count
)
# Update delivery in database
from ...shared.config.database import SessionLocal
db = SessionLocal()
try:
db.merge(delivery)
db.commit()
except Exception as e:
logger.error(f'Error updating webhook delivery: {str(e)}')
db.rollback()
finally:
db.close()
webhook_service = WebhookService()