feat: implement custom fields, gantt view, calendar view, and file encryption

- 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>
This commit is contained in:
beabigegg
2026-01-05 23:39:12 +08:00
parent 69b81d9241
commit 2d80a8384e
65 changed files with 11045 additions and 82 deletions

View File

@@ -0,0 +1,299 @@
"""
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}