""" Credential service layer for business logic and database operations. This module handles all database operations for credentials with encryption, providing secure storage and retrieval of sensitive authentication data. """ import json from datetime import datetime from typing import Optional from uuid import UUID from fastapi import HTTPException, status from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from api.models.credential import Credential from api.models.credential_audit_log import CredentialAuditLog from api.schemas.credential import CredentialCreate, CredentialUpdate from api.utils.crypto import encrypt_string, decrypt_string def _create_audit_log( db: Session, credential_id: str, action: str, user_id: str, ip_address: Optional[str] = None, user_agent: Optional[str] = None, details: Optional[dict] = None, ) -> None: """ Create an audit log entry for credential operations. Args: db: Database session credential_id: ID of the credential being accessed action: Action performed (view, create, update, delete, rotate, decrypt) user_id: User performing the action ip_address: Optional IP address of the user user_agent: Optional user agent string details: Optional dictionary with additional context (will be JSON serialized) Note: This is an internal helper function. Never log decrypted passwords. """ try: audit_entry = CredentialAuditLog( credential_id=credential_id, action=action, user_id=user_id, ip_address=ip_address, user_agent=user_agent, details=json.dumps(details) if details else None, ) db.add(audit_entry) db.commit() except Exception as e: # Log but don't fail the operation if audit logging fails db.rollback() print(f"Warning: Failed to create audit log: {str(e)}") def get_credentials(db: Session, skip: int = 0, limit: int = 100) -> tuple[list[Credential], int]: """ Retrieve a paginated list of credentials. Args: db: Database session skip: Number of records to skip (for pagination) limit: Maximum number of records to return Returns: tuple: (list of credentials, total count) Example: ```python credentials, total = get_credentials(db, skip=0, limit=50) print(f"Retrieved {len(credentials)} of {total} credentials") ``` """ # Get total count total = db.query(Credential).count() # Get paginated results, ordered by created_at descending (newest first) credentials = ( db.query(Credential) .order_by(Credential.created_at.desc()) .offset(skip) .limit(limit) .all() ) return credentials, total def get_credential_by_id(db: Session, credential_id: UUID, user_id: Optional[str] = None) -> Credential: """ Retrieve a single credential by its ID. Args: db: Database session credential_id: UUID of the credential to retrieve user_id: Optional user ID for audit logging Returns: Credential: The credential object Raises: HTTPException: 404 if credential not found Example: ```python credential = get_credential_by_id(db, credential_id, user_id="user123") print(f"Found credential: {credential.service_name}") ``` """ credential = db.query(Credential).filter(Credential.id == str(credential_id)).first() if not credential: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Credential with ID {credential_id} not found" ) # Create audit log for view action if user_id: _create_audit_log( db=db, credential_id=str(credential_id), action="view", user_id=user_id, details={"service_name": credential.service_name} ) return credential def get_credentials_by_client( db: Session, client_id: UUID, skip: int = 0, limit: int = 100 ) -> tuple[list[Credential], int]: """ Retrieve credentials for a specific client. Args: db: Database session client_id: UUID of the client skip: Number of records to skip limit: Maximum number of records to return Returns: tuple: (list of credentials, total count) Example: ```python credentials, total = get_credentials_by_client(db, client_id, skip=0, limit=50) print(f"Client has {total} credentials") ``` """ # Get total count for this client total = db.query(Credential).filter(Credential.client_id == str(client_id)).count() # Get paginated results credentials = ( db.query(Credential) .filter(Credential.client_id == str(client_id)) .order_by(Credential.created_at.desc()) .offset(skip) .limit(limit) .all() ) return credentials, total def create_credential( db: Session, credential_data: CredentialCreate, user_id: str, ip_address: Optional[str] = None, user_agent: Optional[str] = None, ) -> Credential: """ Create a new credential with encryption. Args: db: Database session credential_data: Credential creation data user_id: User creating the credential ip_address: Optional IP address user_agent: Optional user agent string Returns: Credential: The created credential object Raises: HTTPException: 500 if database error occurs Example: ```python credential_data = CredentialCreate( service_name="Gitea Admin", credential_type="password", username="admin", password="SecurePassword123!" ) credential = create_credential(db, credential_data, user_id="user123") print(f"Created credential: {credential.id}") ``` Security: All sensitive fields (password, api_key, etc.) are encrypted before storage. """ try: # Convert Pydantic model to dict, excluding unset values data = credential_data.model_dump(exclude_unset=True) # Encrypt sensitive fields if present if "password" in data and data["password"]: encrypted_password = encrypt_string(data["password"]) data["password_encrypted"] = encrypted_password.encode('utf-8') del data["password"] if "api_key" in data and data["api_key"]: encrypted_api_key = encrypt_string(data["api_key"]) data["api_key_encrypted"] = encrypted_api_key.encode('utf-8') del data["api_key"] if "client_secret" in data and data["client_secret"]: encrypted_secret = encrypt_string(data["client_secret"]) data["client_secret_encrypted"] = encrypted_secret.encode('utf-8') del data["client_secret"] if "token" in data and data["token"]: encrypted_token = encrypt_string(data["token"]) data["token_encrypted"] = encrypted_token.encode('utf-8') del data["token"] if "connection_string" in data and data["connection_string"]: encrypted_conn = encrypt_string(data["connection_string"]) data["connection_string_encrypted"] = encrypted_conn.encode('utf-8') del data["connection_string"] # Convert UUID fields to strings if "client_id" in data and data["client_id"]: data["client_id"] = str(data["client_id"]) if "service_id" in data and data["service_id"]: data["service_id"] = str(data["service_id"]) if "infrastructure_id" in data and data["infrastructure_id"]: data["infrastructure_id"] = str(data["infrastructure_id"]) # Create new credential instance db_credential = Credential(**data) # Add to database db.add(db_credential) db.commit() db.refresh(db_credential) # Create audit log _create_audit_log( db=db, credential_id=str(db_credential.id), action="create", user_id=user_id, ip_address=ip_address, user_agent=user_agent, details={ "service_name": db_credential.service_name, "credential_type": db_credential.credential_type } ) return db_credential except IntegrityError as e: db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Database integrity error: {str(e)}" ) except Exception as e: db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create credential: {str(e)}" ) def update_credential( db: Session, credential_id: UUID, credential_data: CredentialUpdate, user_id: str, ip_address: Optional[str] = None, user_agent: Optional[str] = None, ) -> Credential: """ Update an existing credential with re-encryption if needed. Args: db: Database session credential_id: UUID of the credential to update credential_data: Credential update data (only provided fields will be updated) user_id: User updating the credential ip_address: Optional IP address user_agent: Optional user agent string Returns: Credential: The updated credential object Raises: HTTPException: 404 if credential not found HTTPException: 500 if database error occurs Example: ```python update_data = CredentialUpdate( password="NewSecurePassword456!", last_rotated_at=datetime.utcnow() ) credential = update_credential(db, credential_id, update_data, user_id="user123") print(f"Updated credential: {credential.service_name}") ``` Security: If sensitive fields are updated, they are re-encrypted before storage. """ # Get existing credential credential = get_credential_by_id(db, credential_id) try: # Update only provided fields update_data = credential_data.model_dump(exclude_unset=True) changed_fields = [] # Track what changed for audit log for field in update_data.keys(): if field not in ["password", "api_key", "client_secret", "token", "connection_string"]: changed_fields.append(field) # Encrypt sensitive fields if present in update if "password" in update_data and update_data["password"]: encrypted_password = encrypt_string(update_data["password"]) update_data["password_encrypted"] = encrypted_password.encode('utf-8') del update_data["password"] changed_fields.append("password") if "api_key" in update_data and update_data["api_key"]: encrypted_api_key = encrypt_string(update_data["api_key"]) update_data["api_key_encrypted"] = encrypted_api_key.encode('utf-8') del update_data["api_key"] changed_fields.append("api_key") if "client_secret" in update_data and update_data["client_secret"]: encrypted_secret = encrypt_string(update_data["client_secret"]) update_data["client_secret_encrypted"] = encrypted_secret.encode('utf-8') del update_data["client_secret"] changed_fields.append("client_secret") if "token" in update_data and update_data["token"]: encrypted_token = encrypt_string(update_data["token"]) update_data["token_encrypted"] = encrypted_token.encode('utf-8') del update_data["token"] changed_fields.append("token") if "connection_string" in update_data and update_data["connection_string"]: encrypted_conn = encrypt_string(update_data["connection_string"]) update_data["connection_string_encrypted"] = encrypted_conn.encode('utf-8') del update_data["connection_string"] changed_fields.append("connection_string") # Convert UUID fields to strings if "client_id" in update_data and update_data["client_id"]: update_data["client_id"] = str(update_data["client_id"]) if "service_id" in update_data and update_data["service_id"]: update_data["service_id"] = str(update_data["service_id"]) if "infrastructure_id" in update_data and update_data["infrastructure_id"]: update_data["infrastructure_id"] = str(update_data["infrastructure_id"]) # Apply updates for field, value in update_data.items(): setattr(credential, field, value) db.commit() db.refresh(credential) # Create audit log _create_audit_log( db=db, credential_id=str(credential_id), action="update", user_id=user_id, ip_address=ip_address, user_agent=user_agent, details={ "changed_fields": changed_fields, "service_name": credential.service_name } ) return credential except HTTPException: db.rollback() raise except IntegrityError as e: db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Database integrity error: {str(e)}" ) except Exception as e: db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update credential: {str(e)}" ) def delete_credential( db: Session, credential_id: UUID, user_id: str, ip_address: Optional[str] = None, user_agent: Optional[str] = None, ) -> dict: """ Delete a credential by its ID. Args: db: Database session credential_id: UUID of the credential to delete user_id: User deleting the credential ip_address: Optional IP address user_agent: Optional user agent string Returns: dict: Success message Raises: HTTPException: 404 if credential not found HTTPException: 500 if database error occurs Example: ```python result = delete_credential(db, credential_id, user_id="user123") print(result["message"]) # "Credential deleted successfully" ``` Security: Deletion is audited. The audit log is retained even after credential deletion due to CASCADE delete behavior on the credential_audit_log table. """ # Get existing credential (raises 404 if not found) credential = get_credential_by_id(db, credential_id) # Store info for audit log before deletion service_name = credential.service_name credential_type = credential.credential_type try: # Create audit log BEFORE deletion _create_audit_log( db=db, credential_id=str(credential_id), action="delete", user_id=user_id, ip_address=ip_address, user_agent=user_agent, details={ "service_name": service_name, "credential_type": credential_type } ) db.delete(credential) db.commit() return { "message": "Credential deleted successfully", "credential_id": str(credential_id) } except Exception as e: db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete credential: {str(e)}" )