- Custom Fields (FEAT-001): - CustomField and TaskCustomValue models with formula support - CRUD API for custom field management - Formula engine for calculated fields - Frontend: CustomFieldEditor, CustomFieldInput, ProjectSettings page - Task list API now includes custom_values - KanbanBoard displays custom field values - Gantt View (FEAT-003): - TaskDependency model with FS/SS/FF/SF dependency types - Dependency CRUD API with cycle detection - start_date field added to tasks - GanttChart component with Frappe Gantt integration - Dependency type selector in UI - Calendar View (FEAT-004): - CalendarView component with FullCalendar integration - Date range filtering API for tasks - Drag-and-drop date updates - View mode switching in Tasks page - File Encryption (FEAT-010): - AES-256-GCM encryption service - EncryptionKey model with key rotation support - Admin API for key management - Encrypted upload/download for confidential projects - Migrations: 011 (custom fields), 012 (encryption keys), 013 (task dependencies) - Updated issues.md with completion status 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
300 lines
9.1 KiB
Python
300 lines
9.1 KiB
Python
"""
|
|
Encryption Key Management API (Admin only).
|
|
|
|
Provides endpoints for:
|
|
- Listing encryption keys (without actual key data)
|
|
- Creating new encryption keys
|
|
- Key rotation
|
|
- Checking encryption status
|
|
"""
|
|
import uuid
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from sqlalchemy.orm import Session
|
|
from typing import Optional
|
|
|
|
from app.core.database import get_db
|
|
from app.middleware.auth import get_current_user, require_system_admin
|
|
from app.models import User, EncryptionKey, AuditAction
|
|
from app.schemas.encryption_key import (
|
|
EncryptionKeyResponse,
|
|
EncryptionKeyListResponse,
|
|
EncryptionKeyCreateResponse,
|
|
EncryptionKeyRotateResponse,
|
|
EncryptionStatusResponse,
|
|
)
|
|
from app.services.encryption_service import (
|
|
encryption_service,
|
|
MasterKeyNotConfiguredError,
|
|
)
|
|
from app.services.audit_service import AuditService
|
|
|
|
router = APIRouter(prefix="/api/admin/encryption-keys", tags=["Admin - Encryption Keys"])
|
|
|
|
|
|
def key_to_response(key: EncryptionKey) -> EncryptionKeyResponse:
|
|
"""Convert EncryptionKey model to response (without key data)."""
|
|
return EncryptionKeyResponse(
|
|
id=key.id,
|
|
algorithm=key.algorithm,
|
|
is_active=key.is_active,
|
|
created_at=key.created_at,
|
|
rotated_at=key.rotated_at,
|
|
)
|
|
|
|
|
|
@router.get("/status", response_model=EncryptionStatusResponse)
|
|
async def get_encryption_status(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_system_admin),
|
|
):
|
|
"""
|
|
Get the current encryption status.
|
|
|
|
Returns whether encryption is available, active key info, and total key count.
|
|
"""
|
|
encryption_available = encryption_service.is_encryption_available()
|
|
|
|
active_key = None
|
|
total_keys = 0
|
|
|
|
if encryption_available:
|
|
active_key = db.query(EncryptionKey).filter(
|
|
EncryptionKey.is_active == True
|
|
).first()
|
|
total_keys = db.query(EncryptionKey).count()
|
|
|
|
message = "Encryption is available" if encryption_available else "Encryption is not configured (ENCRYPTION_MASTER_KEY not set)"
|
|
|
|
return EncryptionStatusResponse(
|
|
encryption_available=encryption_available,
|
|
active_key_id=active_key.id if active_key else None,
|
|
total_keys=total_keys,
|
|
message=message,
|
|
)
|
|
|
|
|
|
@router.get("", response_model=EncryptionKeyListResponse)
|
|
async def list_encryption_keys(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_system_admin),
|
|
):
|
|
"""
|
|
List all encryption keys (without actual key data).
|
|
|
|
Only accessible by system administrators.
|
|
"""
|
|
keys = db.query(EncryptionKey).order_by(EncryptionKey.created_at.desc()).all()
|
|
|
|
return EncryptionKeyListResponse(
|
|
keys=[key_to_response(k) for k in keys],
|
|
total=len(keys),
|
|
)
|
|
|
|
|
|
@router.post("", response_model=EncryptionKeyCreateResponse)
|
|
async def create_encryption_key(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_system_admin),
|
|
):
|
|
"""
|
|
Create a new encryption key.
|
|
|
|
The key is generated, encrypted with the Master Key, and stored.
|
|
This does NOT automatically make it the active key - use rotate for that.
|
|
"""
|
|
if not encryption_service.is_encryption_available():
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Encryption is not configured. Set ENCRYPTION_MASTER_KEY in environment."
|
|
)
|
|
|
|
try:
|
|
# Generate new key
|
|
raw_key = encryption_service.generate_key()
|
|
|
|
# Encrypt with master key
|
|
encrypted_key = encryption_service.encrypt_key(raw_key)
|
|
|
|
# Create key record (not active by default)
|
|
key = EncryptionKey(
|
|
id=str(uuid.uuid4()),
|
|
key_data=encrypted_key,
|
|
algorithm="AES-256-GCM",
|
|
is_active=False,
|
|
)
|
|
db.add(key)
|
|
|
|
# Audit log
|
|
AuditService.log_event(
|
|
db=db,
|
|
event_type="encryption.key_create",
|
|
resource_type="encryption_key",
|
|
action=AuditAction.CREATE,
|
|
user_id=current_user.id,
|
|
resource_id=key.id,
|
|
changes=[{"field": "algorithm", "old_value": None, "new_value": "AES-256-GCM"}],
|
|
request_metadata=getattr(request.state, "audit_metadata", None),
|
|
)
|
|
|
|
db.commit()
|
|
db.refresh(key)
|
|
|
|
return EncryptionKeyCreateResponse(
|
|
id=key.id,
|
|
algorithm=key.algorithm,
|
|
is_active=key.is_active,
|
|
created_at=key.created_at,
|
|
message="Encryption key created successfully. Use /rotate to make it active.",
|
|
)
|
|
|
|
except MasterKeyNotConfiguredError:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Master key is not configured"
|
|
)
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to create encryption key: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.post("/rotate", response_model=EncryptionKeyRotateResponse)
|
|
async def rotate_encryption_key(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_system_admin),
|
|
):
|
|
"""
|
|
Rotate to a new encryption key.
|
|
|
|
This will:
|
|
1. Create a new encryption key
|
|
2. Mark the new key as active
|
|
3. Mark the old active key as inactive (but keep it for decrypting old files)
|
|
|
|
After rotation, new file uploads will use the new key.
|
|
Old files remain readable using their original keys.
|
|
"""
|
|
if not encryption_service.is_encryption_available():
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Encryption is not configured. Set ENCRYPTION_MASTER_KEY in environment."
|
|
)
|
|
|
|
try:
|
|
# Find current active key
|
|
old_active_key = db.query(EncryptionKey).filter(
|
|
EncryptionKey.is_active == True
|
|
).first()
|
|
|
|
# Generate new key
|
|
raw_key = encryption_service.generate_key()
|
|
encrypted_key = encryption_service.encrypt_key(raw_key)
|
|
|
|
# Create new key as active
|
|
new_key = EncryptionKey(
|
|
id=str(uuid.uuid4()),
|
|
key_data=encrypted_key,
|
|
algorithm="AES-256-GCM",
|
|
is_active=True,
|
|
)
|
|
db.add(new_key)
|
|
|
|
# Deactivate old key if exists
|
|
old_key_id = None
|
|
if old_active_key:
|
|
old_active_key.is_active = False
|
|
old_active_key.rotated_at = datetime.utcnow()
|
|
old_key_id = old_active_key.id
|
|
|
|
# Audit log for rotation
|
|
AuditService.log_event(
|
|
db=db,
|
|
event_type="encryption.key_rotate",
|
|
resource_type="encryption_key",
|
|
action=AuditAction.UPDATE,
|
|
user_id=current_user.id,
|
|
resource_id=new_key.id,
|
|
changes=[
|
|
{"field": "rotation", "old_value": old_key_id, "new_value": new_key.id},
|
|
{"field": "is_active", "old_value": False, "new_value": True},
|
|
],
|
|
request_metadata=getattr(request.state, "audit_metadata", None),
|
|
)
|
|
|
|
db.commit()
|
|
|
|
return EncryptionKeyRotateResponse(
|
|
new_key_id=new_key.id,
|
|
old_key_id=old_key_id,
|
|
message="Key rotation completed successfully. New uploads will use the new key.",
|
|
)
|
|
|
|
except MasterKeyNotConfiguredError:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Master key is not configured"
|
|
)
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to rotate encryption key: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.delete("/{key_id}")
|
|
async def deactivate_encryption_key(
|
|
key_id: str,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_system_admin),
|
|
):
|
|
"""
|
|
Deactivate an encryption key.
|
|
|
|
Note: This does NOT delete the key. Keys are never deleted to ensure
|
|
encrypted files can always be decrypted.
|
|
|
|
If this is the only active key, you must rotate to a new key first.
|
|
"""
|
|
key = db.query(EncryptionKey).filter(EncryptionKey.id == key_id).first()
|
|
if not key:
|
|
raise HTTPException(status_code=404, detail="Encryption key not found")
|
|
|
|
if key.is_active:
|
|
# Check if there are other active keys
|
|
other_active = db.query(EncryptionKey).filter(
|
|
EncryptionKey.is_active == True,
|
|
EncryptionKey.id != key_id
|
|
).first()
|
|
|
|
if not other_active:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Cannot deactivate the only active key. Rotate to a new key first."
|
|
)
|
|
|
|
key.is_active = False
|
|
key.rotated_at = datetime.utcnow()
|
|
|
|
# Audit log
|
|
AuditService.log_event(
|
|
db=db,
|
|
event_type="encryption.key_deactivate",
|
|
resource_type="encryption_key",
|
|
action=AuditAction.UPDATE,
|
|
user_id=current_user.id,
|
|
resource_id=key.id,
|
|
changes=[{"field": "is_active", "old_value": True, "new_value": False}],
|
|
request_metadata=getattr(request.state, "audit_metadata", None),
|
|
)
|
|
|
|
db.commit()
|
|
|
|
return {"detail": "Encryption key deactivated", "id": key_id}
|