""" ISO 27001 ISMS API Routes Provides endpoints for ISO 27001 certification-ready ISMS management: - Scope & Context (Kapitel 4) - Policies & Objectives (Kapitel 5, 6) - Statement of Applicability (SoA) - Audit Findings & CAPA (Kapitel 9, 10) - Management Reviews (Kapitel 9.3) - Internal Audits (Kapitel 9.2) - ISMS Readiness Check """ import uuid import hashlib from datetime import datetime, date from typing import Optional, List from fastapi import APIRouter, HTTPException, Query, Depends from sqlalchemy.orm import Session from ..db.models import ( ISMSScopeDB, ISMSContextDB, ISMSPolicyDB, SecurityObjectiveDB, StatementOfApplicabilityDB, AuditFindingDB, CorrectiveActionDB, ManagementReviewDB, InternalAuditDB, AuditTrailDB, ISMSReadinessCheckDB, ApprovalStatusEnum, FindingTypeEnum, FindingStatusEnum, CAPATypeEnum ) from .schemas import ( # Scope ISMSScopeCreate, ISMSScopeUpdate, ISMSScopeResponse, ISMSScopeApproveRequest, # Context ISMSContextCreate, ISMSContextResponse, # Policies ISMSPolicyCreate, ISMSPolicyUpdate, ISMSPolicyResponse, ISMSPolicyListResponse, ISMSPolicyApproveRequest, # Objectives SecurityObjectiveCreate, SecurityObjectiveUpdate, SecurityObjectiveResponse, SecurityObjectiveListResponse, # SoA SoAEntryCreate, SoAEntryUpdate, SoAEntryResponse, SoAListResponse, SoAApproveRequest, # Findings AuditFindingCreate, AuditFindingUpdate, AuditFindingResponse, AuditFindingListResponse, AuditFindingCloseRequest, # CAPA CorrectiveActionCreate, CorrectiveActionUpdate, CorrectiveActionResponse, CorrectiveActionListResponse, CAPAVerifyRequest, # Management Review ManagementReviewCreate, ManagementReviewUpdate, ManagementReviewResponse, ManagementReviewListResponse, ManagementReviewApproveRequest, # Internal Audit InternalAuditCreate, InternalAuditUpdate, InternalAuditResponse, InternalAuditListResponse, InternalAuditCompleteRequest, # Readiness ISMSReadinessCheckResponse, ISMSReadinessCheckRequest, PotentialFinding, # Audit Trail AuditTrailResponse, AuditTrailEntry, PaginationMeta, # Overview ISO27001OverviewResponse, ISO27001ChapterStatus ) # Import database session dependency from classroom_engine.database import get_db router = APIRouter(prefix="/isms", tags=["ISMS"]) # ============================================================================= # Helper Functions # ============================================================================= def generate_id() -> str: """Generate a UUID string.""" return str(uuid.uuid4()) def create_signature(data: str) -> str: """Create SHA-256 signature.""" return hashlib.sha256(data.encode()).hexdigest() def log_audit_trail( db: Session, entity_type: str, entity_id: str, entity_name: str, action: str, performed_by: str, field_changed: str = None, old_value: str = None, new_value: str = None, change_summary: str = None ): """Log an entry to the audit trail.""" trail = AuditTrailDB( id=generate_id(), entity_type=entity_type, entity_id=entity_id, entity_name=entity_name, action=action, field_changed=field_changed, old_value=old_value, new_value=new_value, change_summary=change_summary, performed_by=performed_by, performed_at=datetime.utcnow(), checksum=create_signature(f"{entity_type}|{entity_id}|{action}|{performed_by}") ) db.add(trail) # ============================================================================= # ISMS SCOPE (ISO 27001 4.3) # ============================================================================= @router.get("/scope", response_model=ISMSScopeResponse) async def get_isms_scope(db: Session = Depends(get_db)): """ Get the current ISMS scope. The scope defines the boundaries and applicability of the ISMS. Only one active scope should exist at a time. """ scope = db.query(ISMSScopeDB).filter( ISMSScopeDB.status != ApprovalStatusEnum.SUPERSEDED ).order_by(ISMSScopeDB.created_at.desc()).first() if not scope: raise HTTPException(status_code=404, detail="No ISMS scope defined yet") return scope @router.post("/scope", response_model=ISMSScopeResponse) async def create_isms_scope( data: ISMSScopeCreate, created_by: str = Query(..., description="User creating the scope"), db: Session = Depends(get_db) ): """ Create a new ISMS scope definition. Supersedes any existing scope. """ # Supersede existing scopes existing = db.query(ISMSScopeDB).filter( ISMSScopeDB.status != ApprovalStatusEnum.SUPERSEDED ).all() for s in existing: s.status = ApprovalStatusEnum.SUPERSEDED scope = ISMSScopeDB( id=generate_id(), scope_statement=data.scope_statement, included_locations=data.included_locations, included_processes=data.included_processes, included_services=data.included_services, excluded_items=data.excluded_items, exclusion_justification=data.exclusion_justification, organizational_boundary=data.organizational_boundary, physical_boundary=data.physical_boundary, technical_boundary=data.technical_boundary, status=ApprovalStatusEnum.DRAFT, created_by=created_by ) db.add(scope) log_audit_trail(db, "isms_scope", scope.id, "ISMS Scope", "create", created_by) db.commit() db.refresh(scope) return scope @router.put("/scope/{scope_id}", response_model=ISMSScopeResponse) async def update_isms_scope( scope_id: str, data: ISMSScopeUpdate, updated_by: str = Query(..., description="User updating the scope"), db: Session = Depends(get_db) ): """Update ISMS scope (only if in draft status).""" scope = db.query(ISMSScopeDB).filter(ISMSScopeDB.id == scope_id).first() if not scope: raise HTTPException(status_code=404, detail="Scope not found") if scope.status == ApprovalStatusEnum.APPROVED: raise HTTPException(status_code=400, detail="Cannot modify approved scope. Create new version.") for field, value in data.model_dump(exclude_unset=True).items(): setattr(scope, field, value) scope.updated_by = updated_by scope.updated_at = datetime.utcnow() # Increment version if significant changes version_parts = scope.version.split(".") scope.version = f"{version_parts[0]}.{int(version_parts[1]) + 1}" log_audit_trail(db, "isms_scope", scope.id, "ISMS Scope", "update", updated_by) db.commit() db.refresh(scope) return scope @router.post("/scope/{scope_id}/approve", response_model=ISMSScopeResponse) async def approve_isms_scope( scope_id: str, data: ISMSScopeApproveRequest, db: Session = Depends(get_db) ): """ Approve the ISMS scope. This is a MANDATORY step for ISO 27001 certification. Must be approved by top management. """ scope = db.query(ISMSScopeDB).filter(ISMSScopeDB.id == scope_id).first() if not scope: raise HTTPException(status_code=404, detail="Scope not found") scope.status = ApprovalStatusEnum.APPROVED scope.approved_by = data.approved_by scope.approved_at = datetime.utcnow() scope.effective_date = data.effective_date scope.review_date = data.review_date scope.approval_signature = create_signature( f"{scope.scope_statement}|{data.approved_by}|{datetime.utcnow().isoformat()}" ) log_audit_trail(db, "isms_scope", scope.id, "ISMS Scope", "approve", data.approved_by) db.commit() db.refresh(scope) return scope # ============================================================================= # ISMS CONTEXT (ISO 27001 4.1, 4.2) # ============================================================================= @router.get("/context", response_model=ISMSContextResponse) async def get_isms_context(db: Session = Depends(get_db)): """Get the current ISMS context analysis.""" context = db.query(ISMSContextDB).filter( ISMSContextDB.status != ApprovalStatusEnum.SUPERSEDED ).order_by(ISMSContextDB.created_at.desc()).first() if not context: raise HTTPException(status_code=404, detail="No ISMS context defined yet") return context @router.post("/context", response_model=ISMSContextResponse) async def create_isms_context( data: ISMSContextCreate, created_by: str = Query(...), db: Session = Depends(get_db) ): """Create or update ISMS context analysis.""" # Supersede existing existing = db.query(ISMSContextDB).filter( ISMSContextDB.status != ApprovalStatusEnum.SUPERSEDED ).all() for c in existing: c.status = ApprovalStatusEnum.SUPERSEDED context = ISMSContextDB( id=generate_id(), internal_issues=[i.model_dump() for i in data.internal_issues] if data.internal_issues else None, external_issues=[i.model_dump() for i in data.external_issues] if data.external_issues else None, interested_parties=[p.model_dump() for p in data.interested_parties] if data.interested_parties else None, regulatory_requirements=data.regulatory_requirements, contractual_requirements=data.contractual_requirements, swot_strengths=data.swot_strengths, swot_weaknesses=data.swot_weaknesses, swot_opportunities=data.swot_opportunities, swot_threats=data.swot_threats, status=ApprovalStatusEnum.DRAFT ) db.add(context) log_audit_trail(db, "isms_context", context.id, "ISMS Context", "create", created_by) db.commit() db.refresh(context) return context # ============================================================================= # ISMS POLICIES (ISO 27001 5.2) # ============================================================================= @router.get("/policies", response_model=ISMSPolicyListResponse) async def list_policies( policy_type: Optional[str] = None, status: Optional[str] = None, db: Session = Depends(get_db) ): """List all ISMS policies.""" query = db.query(ISMSPolicyDB) if policy_type: query = query.filter(ISMSPolicyDB.policy_type == policy_type) if status: query = query.filter(ISMSPolicyDB.status == status) policies = query.order_by(ISMSPolicyDB.policy_id).all() return ISMSPolicyListResponse(policies=policies, total=len(policies)) @router.post("/policies", response_model=ISMSPolicyResponse) async def create_policy(data: ISMSPolicyCreate, db: Session = Depends(get_db)): """Create a new ISMS policy.""" # Check for duplicate policy_id existing = db.query(ISMSPolicyDB).filter( ISMSPolicyDB.policy_id == data.policy_id ).first() if existing: raise HTTPException(status_code=400, detail=f"Policy {data.policy_id} already exists") policy = ISMSPolicyDB( id=generate_id(), policy_id=data.policy_id, title=data.title, policy_type=data.policy_type, description=data.description, policy_text=data.policy_text, applies_to=data.applies_to, review_frequency_months=data.review_frequency_months, related_controls=data.related_controls, authored_by=data.authored_by, status=ApprovalStatusEnum.DRAFT ) db.add(policy) log_audit_trail(db, "isms_policy", policy.id, policy.policy_id, "create", data.authored_by) db.commit() db.refresh(policy) return policy @router.get("/policies/{policy_id}", response_model=ISMSPolicyResponse) async def get_policy(policy_id: str, db: Session = Depends(get_db)): """Get a specific policy by ID.""" policy = db.query(ISMSPolicyDB).filter( (ISMSPolicyDB.id == policy_id) | (ISMSPolicyDB.policy_id == policy_id) ).first() if not policy: raise HTTPException(status_code=404, detail="Policy not found") return policy @router.put("/policies/{policy_id}", response_model=ISMSPolicyResponse) async def update_policy( policy_id: str, data: ISMSPolicyUpdate, updated_by: str = Query(...), db: Session = Depends(get_db) ): """Update a policy (creates new version if approved).""" policy = db.query(ISMSPolicyDB).filter( (ISMSPolicyDB.id == policy_id) | (ISMSPolicyDB.policy_id == policy_id) ).first() if not policy: raise HTTPException(status_code=404, detail="Policy not found") if policy.status == ApprovalStatusEnum.APPROVED: # Increment major version version_parts = policy.version.split(".") policy.version = f"{int(version_parts[0]) + 1}.0" policy.status = ApprovalStatusEnum.DRAFT for field, value in data.model_dump(exclude_unset=True).items(): setattr(policy, field, value) log_audit_trail(db, "isms_policy", policy.id, policy.policy_id, "update", updated_by) db.commit() db.refresh(policy) return policy @router.post("/policies/{policy_id}/approve", response_model=ISMSPolicyResponse) async def approve_policy( policy_id: str, data: ISMSPolicyApproveRequest, db: Session = Depends(get_db) ): """Approve a policy. Must be approved by top management.""" policy = db.query(ISMSPolicyDB).filter( (ISMSPolicyDB.id == policy_id) | (ISMSPolicyDB.policy_id == policy_id) ).first() if not policy: raise HTTPException(status_code=404, detail="Policy not found") policy.reviewed_by = data.reviewed_by policy.approved_by = data.approved_by policy.approved_at = datetime.utcnow() policy.effective_date = data.effective_date policy.next_review_date = date( data.effective_date.year + (policy.review_frequency_months // 12), data.effective_date.month, data.effective_date.day ) policy.status = ApprovalStatusEnum.APPROVED policy.approval_signature = create_signature( f"{policy.policy_id}|{data.approved_by}|{datetime.utcnow().isoformat()}" ) log_audit_trail(db, "isms_policy", policy.id, policy.policy_id, "approve", data.approved_by) db.commit() db.refresh(policy) return policy # ============================================================================= # SECURITY OBJECTIVES (ISO 27001 6.2) # ============================================================================= @router.get("/objectives", response_model=SecurityObjectiveListResponse) async def list_objectives( category: Optional[str] = None, status: Optional[str] = None, db: Session = Depends(get_db) ): """List all security objectives.""" query = db.query(SecurityObjectiveDB) if category: query = query.filter(SecurityObjectiveDB.category == category) if status: query = query.filter(SecurityObjectiveDB.status == status) objectives = query.order_by(SecurityObjectiveDB.objective_id).all() return SecurityObjectiveListResponse(objectives=objectives, total=len(objectives)) @router.post("/objectives", response_model=SecurityObjectiveResponse) async def create_objective( data: SecurityObjectiveCreate, created_by: str = Query(...), db: Session = Depends(get_db) ): """Create a new security objective.""" objective = SecurityObjectiveDB( id=generate_id(), objective_id=data.objective_id, title=data.title, description=data.description, category=data.category, specific=data.specific, measurable=data.measurable, achievable=data.achievable, relevant=data.relevant, time_bound=data.time_bound, kpi_name=data.kpi_name, kpi_target=data.kpi_target, kpi_unit=data.kpi_unit, measurement_frequency=data.measurement_frequency, owner=data.owner, target_date=data.target_date, related_controls=data.related_controls, related_risks=data.related_risks, status="active" ) db.add(objective) log_audit_trail(db, "security_objective", objective.id, objective.objective_id, "create", created_by) db.commit() db.refresh(objective) return objective @router.put("/objectives/{objective_id}", response_model=SecurityObjectiveResponse) async def update_objective( objective_id: str, data: SecurityObjectiveUpdate, updated_by: str = Query(...), db: Session = Depends(get_db) ): """Update a security objective's progress.""" objective = db.query(SecurityObjectiveDB).filter( (SecurityObjectiveDB.id == objective_id) | (SecurityObjectiveDB.objective_id == objective_id) ).first() if not objective: raise HTTPException(status_code=404, detail="Objective not found") for field, value in data.model_dump(exclude_unset=True).items(): setattr(objective, field, value) # Mark as achieved if progress is 100% if objective.progress_percentage >= 100 and objective.status == "active": objective.status = "achieved" objective.achieved_date = date.today() log_audit_trail(db, "security_objective", objective.id, objective.objective_id, "update", updated_by) db.commit() db.refresh(objective) return objective # ============================================================================= # STATEMENT OF APPLICABILITY (SoA) # ============================================================================= @router.get("/soa", response_model=SoAListResponse) async def list_soa_entries( is_applicable: Optional[bool] = None, implementation_status: Optional[str] = None, category: Optional[str] = None, db: Session = Depends(get_db) ): """List all Statement of Applicability entries.""" query = db.query(StatementOfApplicabilityDB) if is_applicable is not None: query = query.filter(StatementOfApplicabilityDB.is_applicable == is_applicable) if implementation_status: query = query.filter(StatementOfApplicabilityDB.implementation_status == implementation_status) if category: query = query.filter(StatementOfApplicabilityDB.annex_a_category == category) entries = query.order_by(StatementOfApplicabilityDB.annex_a_control).all() applicable_count = sum(1 for e in entries if e.is_applicable) implemented_count = sum(1 for e in entries if e.implementation_status == "implemented") planned_count = sum(1 for e in entries if e.implementation_status == "planned") return SoAListResponse( entries=entries, total=len(entries), applicable_count=applicable_count, not_applicable_count=len(entries) - applicable_count, implemented_count=implemented_count, planned_count=planned_count ) @router.post("/soa", response_model=SoAEntryResponse) async def create_soa_entry( data: SoAEntryCreate, created_by: str = Query(...), db: Session = Depends(get_db) ): """Create a new SoA entry for an Annex A control.""" # Check for duplicate existing = db.query(StatementOfApplicabilityDB).filter( StatementOfApplicabilityDB.annex_a_control == data.annex_a_control ).first() if existing: raise HTTPException(status_code=400, detail=f"SoA entry for {data.annex_a_control} already exists") entry = StatementOfApplicabilityDB( id=generate_id(), annex_a_control=data.annex_a_control, annex_a_title=data.annex_a_title, annex_a_category=data.annex_a_category, is_applicable=data.is_applicable, applicability_justification=data.applicability_justification, implementation_status=data.implementation_status, implementation_notes=data.implementation_notes, breakpilot_control_ids=data.breakpilot_control_ids, coverage_level=data.coverage_level, evidence_description=data.evidence_description, risk_assessment_notes=data.risk_assessment_notes, compensating_controls=data.compensating_controls ) db.add(entry) log_audit_trail(db, "soa", entry.id, entry.annex_a_control, "create", created_by) db.commit() db.refresh(entry) return entry @router.put("/soa/{entry_id}", response_model=SoAEntryResponse) async def update_soa_entry( entry_id: str, data: SoAEntryUpdate, updated_by: str = Query(...), db: Session = Depends(get_db) ): """Update an SoA entry.""" entry = db.query(StatementOfApplicabilityDB).filter( (StatementOfApplicabilityDB.id == entry_id) | (StatementOfApplicabilityDB.annex_a_control == entry_id) ).first() if not entry: raise HTTPException(status_code=404, detail="SoA entry not found") for field, value in data.model_dump(exclude_unset=True).items(): setattr(entry, field, value) # Increment version version_parts = entry.version.split(".") entry.version = f"{version_parts[0]}.{int(version_parts[1]) + 1}" log_audit_trail(db, "soa", entry.id, entry.annex_a_control, "update", updated_by) db.commit() db.refresh(entry) return entry @router.post("/soa/{entry_id}/approve", response_model=SoAEntryResponse) async def approve_soa_entry( entry_id: str, data: SoAApproveRequest, db: Session = Depends(get_db) ): """Approve an SoA entry.""" entry = db.query(StatementOfApplicabilityDB).filter( (StatementOfApplicabilityDB.id == entry_id) | (StatementOfApplicabilityDB.annex_a_control == entry_id) ).first() if not entry: raise HTTPException(status_code=404, detail="SoA entry not found") entry.reviewed_by = data.reviewed_by entry.reviewed_at = datetime.utcnow() entry.approved_by = data.approved_by entry.approved_at = datetime.utcnow() log_audit_trail(db, "soa", entry.id, entry.annex_a_control, "approve", data.approved_by) db.commit() db.refresh(entry) return entry # ============================================================================= # AUDIT FINDINGS (Major/Minor/OFI) # ============================================================================= @router.get("/findings", response_model=AuditFindingListResponse) async def list_findings( finding_type: Optional[str] = None, status: Optional[str] = None, internal_audit_id: Optional[str] = None, db: Session = Depends(get_db) ): """List all audit findings.""" query = db.query(AuditFindingDB) if finding_type: query = query.filter(AuditFindingDB.finding_type == finding_type) if status: query = query.filter(AuditFindingDB.status == status) if internal_audit_id: query = query.filter(AuditFindingDB.internal_audit_id == internal_audit_id) findings = query.order_by(AuditFindingDB.identified_date.desc()).all() major_count = sum(1 for f in findings if f.finding_type == FindingTypeEnum.MAJOR) minor_count = sum(1 for f in findings if f.finding_type == FindingTypeEnum.MINOR) ofi_count = sum(1 for f in findings if f.finding_type == FindingTypeEnum.OFI) open_count = sum(1 for f in findings if f.status != FindingStatusEnum.CLOSED) # Add is_blocking property to each finding for f in findings: f.is_blocking = f.finding_type == FindingTypeEnum.MAJOR and f.status != FindingStatusEnum.CLOSED return AuditFindingListResponse( findings=findings, total=len(findings), major_count=major_count, minor_count=minor_count, ofi_count=ofi_count, open_count=open_count ) @router.post("/findings", response_model=AuditFindingResponse) async def create_finding(data: AuditFindingCreate, db: Session = Depends(get_db)): """ Create a new audit finding. Finding types: - major: Blocks certification, requires immediate CAPA - minor: Requires CAPA within deadline - ofi: Opportunity for improvement (no mandatory action) - positive: Good practice observation """ # Generate finding ID year = date.today().year existing_count = db.query(AuditFindingDB).filter( AuditFindingDB.finding_id.like(f"FIND-{year}-%") ).count() finding_id = f"FIND-{year}-{existing_count + 1:03d}" finding = AuditFindingDB( id=generate_id(), finding_id=finding_id, audit_session_id=data.audit_session_id, internal_audit_id=data.internal_audit_id, finding_type=FindingTypeEnum(data.finding_type), iso_chapter=data.iso_chapter, annex_a_control=data.annex_a_control, title=data.title, description=data.description, objective_evidence=data.objective_evidence, impact_description=data.impact_description, affected_processes=data.affected_processes, affected_assets=data.affected_assets, owner=data.owner, auditor=data.auditor, due_date=data.due_date, status=FindingStatusEnum.OPEN ) db.add(finding) # Update internal audit counts if linked if data.internal_audit_id: audit = db.query(InternalAuditDB).filter( InternalAuditDB.id == data.internal_audit_id ).first() if audit: audit.total_findings = (audit.total_findings or 0) + 1 if data.finding_type == "major": audit.major_findings = (audit.major_findings or 0) + 1 elif data.finding_type == "minor": audit.minor_findings = (audit.minor_findings or 0) + 1 elif data.finding_type == "ofi": audit.ofi_count = (audit.ofi_count or 0) + 1 elif data.finding_type == "positive": audit.positive_observations = (audit.positive_observations or 0) + 1 log_audit_trail(db, "audit_finding", finding.id, finding_id, "create", data.auditor) db.commit() db.refresh(finding) finding.is_blocking = finding.finding_type == FindingTypeEnum.MAJOR return finding @router.put("/findings/{finding_id}", response_model=AuditFindingResponse) async def update_finding( finding_id: str, data: AuditFindingUpdate, updated_by: str = Query(...), db: Session = Depends(get_db) ): """Update an audit finding.""" finding = db.query(AuditFindingDB).filter( (AuditFindingDB.id == finding_id) | (AuditFindingDB.finding_id == finding_id) ).first() if not finding: raise HTTPException(status_code=404, detail="Finding not found") for field, value in data.model_dump(exclude_unset=True).items(): if field == "status" and value: setattr(finding, field, FindingStatusEnum(value)) else: setattr(finding, field, value) log_audit_trail(db, "audit_finding", finding.id, finding.finding_id, "update", updated_by) db.commit() db.refresh(finding) finding.is_blocking = finding.finding_type == FindingTypeEnum.MAJOR and finding.status != FindingStatusEnum.CLOSED return finding @router.post("/findings/{finding_id}/close", response_model=AuditFindingResponse) async def close_finding( finding_id: str, data: AuditFindingCloseRequest, db: Session = Depends(get_db) ): """ Close an audit finding after verification. Requires: - All CAPAs to be completed and verified - Verification evidence documenting the fix """ finding = db.query(AuditFindingDB).filter( (AuditFindingDB.id == finding_id) | (AuditFindingDB.finding_id == finding_id) ).first() if not finding: raise HTTPException(status_code=404, detail="Finding not found") # Check if all CAPAs are verified open_capas = db.query(CorrectiveActionDB).filter( CorrectiveActionDB.finding_id == finding.id, CorrectiveActionDB.status != "verified" ).count() if open_capas > 0: raise HTTPException( status_code=400, detail=f"Cannot close finding: {open_capas} CAPA(s) not yet verified" ) finding.status = FindingStatusEnum.CLOSED finding.closed_date = date.today() finding.closure_notes = data.closure_notes finding.closed_by = data.closed_by finding.verification_method = data.verification_method finding.verification_evidence = data.verification_evidence finding.verified_by = data.closed_by finding.verified_at = datetime.utcnow() log_audit_trail(db, "audit_finding", finding.id, finding.finding_id, "close", data.closed_by) db.commit() db.refresh(finding) finding.is_blocking = False return finding # ============================================================================= # CORRECTIVE ACTIONS (CAPA) # ============================================================================= @router.get("/capa", response_model=CorrectiveActionListResponse) async def list_capas( finding_id: Optional[str] = None, status: Optional[str] = None, assigned_to: Optional[str] = None, db: Session = Depends(get_db) ): """List all corrective/preventive actions.""" query = db.query(CorrectiveActionDB) if finding_id: query = query.filter(CorrectiveActionDB.finding_id == finding_id) if status: query = query.filter(CorrectiveActionDB.status == status) if assigned_to: query = query.filter(CorrectiveActionDB.assigned_to == assigned_to) actions = query.order_by(CorrectiveActionDB.planned_completion).all() return CorrectiveActionListResponse(actions=actions, total=len(actions)) @router.post("/capa", response_model=CorrectiveActionResponse) async def create_capa( data: CorrectiveActionCreate, created_by: str = Query(...), db: Session = Depends(get_db) ): """Create a new corrective/preventive action for a finding.""" # Verify finding exists finding = db.query(AuditFindingDB).filter(AuditFindingDB.id == data.finding_id).first() if not finding: raise HTTPException(status_code=404, detail="Finding not found") # Generate CAPA ID year = date.today().year existing_count = db.query(CorrectiveActionDB).filter( CorrectiveActionDB.capa_id.like(f"CAPA-{year}-%") ).count() capa_id = f"CAPA-{year}-{existing_count + 1:03d}" capa = CorrectiveActionDB( id=generate_id(), capa_id=capa_id, finding_id=data.finding_id, capa_type=CAPATypeEnum(data.capa_type), title=data.title, description=data.description, expected_outcome=data.expected_outcome, assigned_to=data.assigned_to, planned_start=data.planned_start, planned_completion=data.planned_completion, effectiveness_criteria=data.effectiveness_criteria, estimated_effort_hours=data.estimated_effort_hours, resources_required=data.resources_required, status="planned" ) db.add(capa) # Update finding status finding.status = FindingStatusEnum.CORRECTIVE_ACTION_PENDING log_audit_trail(db, "capa", capa.id, capa_id, "create", created_by) db.commit() db.refresh(capa) return capa @router.put("/capa/{capa_id}", response_model=CorrectiveActionResponse) async def update_capa( capa_id: str, data: CorrectiveActionUpdate, updated_by: str = Query(...), db: Session = Depends(get_db) ): """Update a CAPA's progress.""" capa = db.query(CorrectiveActionDB).filter( (CorrectiveActionDB.id == capa_id) | (CorrectiveActionDB.capa_id == capa_id) ).first() if not capa: raise HTTPException(status_code=404, detail="CAPA not found") for field, value in data.model_dump(exclude_unset=True).items(): setattr(capa, field, value) # If completed, set actual completion date if capa.status == "completed" and not capa.actual_completion: capa.actual_completion = date.today() log_audit_trail(db, "capa", capa.id, capa.capa_id, "update", updated_by) db.commit() db.refresh(capa) return capa @router.post("/capa/{capa_id}/verify", response_model=CorrectiveActionResponse) async def verify_capa( capa_id: str, data: CAPAVerifyRequest, db: Session = Depends(get_db) ): """Verify the effectiveness of a CAPA.""" capa = db.query(CorrectiveActionDB).filter( (CorrectiveActionDB.id == capa_id) | (CorrectiveActionDB.capa_id == capa_id) ).first() if not capa: raise HTTPException(status_code=404, detail="CAPA not found") if capa.status != "completed": raise HTTPException(status_code=400, detail="CAPA must be completed before verification") capa.effectiveness_verified = data.is_effective capa.effectiveness_verification_date = date.today() capa.effectiveness_notes = data.effectiveness_notes capa.status = "verified" if data.is_effective else "completed" # If verified and all CAPAs for finding are verified, update finding status if data.is_effective: finding = db.query(AuditFindingDB).filter(AuditFindingDB.id == capa.finding_id).first() if finding: unverified = db.query(CorrectiveActionDB).filter( CorrectiveActionDB.finding_id == finding.id, CorrectiveActionDB.id != capa.id, CorrectiveActionDB.status != "verified" ).count() if unverified == 0: finding.status = FindingStatusEnum.VERIFICATION_PENDING log_audit_trail(db, "capa", capa.id, capa.capa_id, "verify", data.verified_by) db.commit() db.refresh(capa) return capa # ============================================================================= # MANAGEMENT REVIEW (ISO 27001 9.3) # ============================================================================= @router.get("/management-reviews", response_model=ManagementReviewListResponse) async def list_management_reviews( status: Optional[str] = None, db: Session = Depends(get_db) ): """List all management reviews.""" query = db.query(ManagementReviewDB) if status: query = query.filter(ManagementReviewDB.status == status) reviews = query.order_by(ManagementReviewDB.review_date.desc()).all() return ManagementReviewListResponse(reviews=reviews, total=len(reviews)) @router.post("/management-reviews", response_model=ManagementReviewResponse) async def create_management_review( data: ManagementReviewCreate, created_by: str = Query(...), db: Session = Depends(get_db) ): """Create a new management review.""" # Generate review ID year = data.review_date.year quarter = (data.review_date.month - 1) // 3 + 1 review_id = f"MR-{year}-Q{quarter}" # Check for duplicate existing = db.query(ManagementReviewDB).filter( ManagementReviewDB.review_id == review_id ).first() if existing: review_id = f"{review_id}-{generate_id()[:4]}" review = ManagementReviewDB( id=generate_id(), review_id=review_id, title=data.title, review_date=data.review_date, review_period_start=data.review_period_start, review_period_end=data.review_period_end, chairperson=data.chairperson, attendees=[a.model_dump() for a in data.attendees] if data.attendees else None, status="draft" ) db.add(review) log_audit_trail(db, "management_review", review.id, review_id, "create", created_by) db.commit() db.refresh(review) return review @router.get("/management-reviews/{review_id}", response_model=ManagementReviewResponse) async def get_management_review(review_id: str, db: Session = Depends(get_db)): """Get a specific management review.""" review = db.query(ManagementReviewDB).filter( (ManagementReviewDB.id == review_id) | (ManagementReviewDB.review_id == review_id) ).first() if not review: raise HTTPException(status_code=404, detail="Management review not found") return review @router.put("/management-reviews/{review_id}", response_model=ManagementReviewResponse) async def update_management_review( review_id: str, data: ManagementReviewUpdate, updated_by: str = Query(...), db: Session = Depends(get_db) ): """Update a management review with inputs/outputs.""" review = db.query(ManagementReviewDB).filter( (ManagementReviewDB.id == review_id) | (ManagementReviewDB.review_id == review_id) ).first() if not review: raise HTTPException(status_code=404, detail="Management review not found") for field, value in data.model_dump(exclude_unset=True).items(): if field == "action_items" and value: setattr(review, field, [item.model_dump() for item in value]) else: setattr(review, field, value) log_audit_trail(db, "management_review", review.id, review.review_id, "update", updated_by) db.commit() db.refresh(review) return review @router.post("/management-reviews/{review_id}/approve", response_model=ManagementReviewResponse) async def approve_management_review( review_id: str, data: ManagementReviewApproveRequest, db: Session = Depends(get_db) ): """Approve a management review.""" review = db.query(ManagementReviewDB).filter( (ManagementReviewDB.id == review_id) | (ManagementReviewDB.review_id == review_id) ).first() if not review: raise HTTPException(status_code=404, detail="Management review not found") review.status = "approved" review.approved_by = data.approved_by review.approved_at = datetime.utcnow() review.next_review_date = data.next_review_date review.minutes_document_path = data.minutes_document_path log_audit_trail(db, "management_review", review.id, review.review_id, "approve", data.approved_by) db.commit() db.refresh(review) return review # ============================================================================= # INTERNAL AUDIT (ISO 27001 9.2) # ============================================================================= @router.get("/internal-audits", response_model=InternalAuditListResponse) async def list_internal_audits( status: Optional[str] = None, audit_type: Optional[str] = None, db: Session = Depends(get_db) ): """List all internal audits.""" query = db.query(InternalAuditDB) if status: query = query.filter(InternalAuditDB.status == status) if audit_type: query = query.filter(InternalAuditDB.audit_type == audit_type) audits = query.order_by(InternalAuditDB.planned_date.desc()).all() return InternalAuditListResponse(audits=audits, total=len(audits)) @router.post("/internal-audits", response_model=InternalAuditResponse) async def create_internal_audit( data: InternalAuditCreate, created_by: str = Query(...), db: Session = Depends(get_db) ): """Create a new internal audit.""" # Generate audit ID year = data.planned_date.year existing_count = db.query(InternalAuditDB).filter( InternalAuditDB.audit_id.like(f"IA-{year}-%") ).count() audit_id = f"IA-{year}-{existing_count + 1:03d}" audit = InternalAuditDB( id=generate_id(), audit_id=audit_id, title=data.title, audit_type=data.audit_type, scope_description=data.scope_description, iso_chapters_covered=data.iso_chapters_covered, annex_a_controls_covered=data.annex_a_controls_covered, processes_covered=data.processes_covered, departments_covered=data.departments_covered, criteria=data.criteria, planned_date=data.planned_date, lead_auditor=data.lead_auditor, audit_team=data.audit_team, status="planned" ) db.add(audit) log_audit_trail(db, "internal_audit", audit.id, audit_id, "create", created_by) db.commit() db.refresh(audit) return audit @router.put("/internal-audits/{audit_id}", response_model=InternalAuditResponse) async def update_internal_audit( audit_id: str, data: InternalAuditUpdate, updated_by: str = Query(...), db: Session = Depends(get_db) ): """Update an internal audit.""" audit = db.query(InternalAuditDB).filter( (InternalAuditDB.id == audit_id) | (InternalAuditDB.audit_id == audit_id) ).first() if not audit: raise HTTPException(status_code=404, detail="Internal audit not found") for field, value in data.model_dump(exclude_unset=True).items(): setattr(audit, field, value) log_audit_trail(db, "internal_audit", audit.id, audit.audit_id, "update", updated_by) db.commit() db.refresh(audit) return audit @router.post("/internal-audits/{audit_id}/complete", response_model=InternalAuditResponse) async def complete_internal_audit( audit_id: str, data: InternalAuditCompleteRequest, completed_by: str = Query(...), db: Session = Depends(get_db) ): """Complete an internal audit with conclusion.""" audit = db.query(InternalAuditDB).filter( (InternalAuditDB.id == audit_id) | (InternalAuditDB.audit_id == audit_id) ).first() if not audit: raise HTTPException(status_code=404, detail="Internal audit not found") audit.status = "completed" audit.actual_end_date = date.today() audit.report_date = date.today() audit.audit_conclusion = data.audit_conclusion audit.overall_assessment = data.overall_assessment audit.follow_up_audit_required = data.follow_up_audit_required log_audit_trail(db, "internal_audit", audit.id, audit.audit_id, "complete", completed_by) db.commit() db.refresh(audit) return audit # ============================================================================= # ISMS READINESS CHECK # ============================================================================= @router.post("/readiness-check", response_model=ISMSReadinessCheckResponse) async def run_readiness_check( data: ISMSReadinessCheckRequest, db: Session = Depends(get_db) ): """ Run ISMS readiness check. Identifies potential Major/Minor findings BEFORE external audit. This helps achieve ISO 27001 certification on the first attempt. """ potential_majors = [] potential_minors = [] improvement_opportunities = [] # Chapter 4: Context scope = db.query(ISMSScopeDB).filter( ISMSScopeDB.status == ApprovalStatusEnum.APPROVED ).first() if not scope: potential_majors.append(PotentialFinding( check="ISMS Scope not approved", status="fail", recommendation="Approve ISMS scope with top management signature", iso_reference="4.3" )) context = db.query(ISMSContextDB).filter( ISMSContextDB.status == ApprovalStatusEnum.APPROVED ).first() if not context: potential_majors.append(PotentialFinding( check="ISMS Context not documented", status="fail", recommendation="Document and approve context analysis (4.1, 4.2)", iso_reference="4.1, 4.2" )) # Chapter 5: Leadership master_policy = db.query(ISMSPolicyDB).filter( ISMSPolicyDB.policy_type == "master", ISMSPolicyDB.status == ApprovalStatusEnum.APPROVED ).first() if not master_policy: potential_majors.append(PotentialFinding( check="Information Security Policy not approved", status="fail", recommendation="Create and approve master ISMS policy", iso_reference="5.2" )) # Chapter 6: Planning - Risk Assessment from ..db.models import RiskDB risks = db.query(RiskDB).filter(RiskDB.status == "open").count() risks_without_treatment = db.query(RiskDB).filter( RiskDB.status == "open", RiskDB.treatment_plan == None ).count() if risks_without_treatment > 0: potential_majors.append(PotentialFinding( check=f"{risks_without_treatment} risks without treatment plan", status="fail", recommendation="Define risk treatment for all identified risks", iso_reference="6.1.2" )) # Chapter 6: Objectives objectives = db.query(SecurityObjectiveDB).filter( SecurityObjectiveDB.status == "active" ).count() if objectives == 0: potential_majors.append(PotentialFinding( check="No security objectives defined", status="fail", recommendation="Define measurable security objectives", iso_reference="6.2" )) # SoA soa_total = db.query(StatementOfApplicabilityDB).count() soa_unapproved = db.query(StatementOfApplicabilityDB).filter( StatementOfApplicabilityDB.approved_at == None ).count() if soa_total == 0: potential_majors.append(PotentialFinding( check="Statement of Applicability not created", status="fail", recommendation="Create SoA for all 93 Annex A controls", iso_reference="Annex A" )) elif soa_unapproved > 0: potential_minors.append(PotentialFinding( check=f"{soa_unapproved} SoA entries not approved", status="warning", recommendation="Review and approve all SoA entries", iso_reference="Annex A" )) # Chapter 9: Internal Audit last_year = date.today().replace(year=date.today().year - 1) internal_audit = db.query(InternalAuditDB).filter( InternalAuditDB.status == "completed", InternalAuditDB.actual_end_date >= last_year ).first() if not internal_audit: potential_majors.append(PotentialFinding( check="No internal audit in last 12 months", status="fail", recommendation="Conduct internal audit before certification", iso_reference="9.2" )) # Chapter 9: Management Review mgmt_review = db.query(ManagementReviewDB).filter( ManagementReviewDB.status == "approved", ManagementReviewDB.review_date >= last_year ).first() if not mgmt_review: potential_majors.append(PotentialFinding( check="No management review in last 12 months", status="fail", recommendation="Conduct and approve management review", iso_reference="9.3" )) # Chapter 10: Open Findings open_majors = db.query(AuditFindingDB).filter( AuditFindingDB.finding_type == FindingTypeEnum.MAJOR, AuditFindingDB.status != FindingStatusEnum.CLOSED ).count() if open_majors > 0: potential_majors.append(PotentialFinding( check=f"{open_majors} open major finding(s)", status="fail", recommendation="Close all major findings before certification", iso_reference="10.1" )) open_minors = db.query(AuditFindingDB).filter( AuditFindingDB.finding_type == FindingTypeEnum.MINOR, AuditFindingDB.status != FindingStatusEnum.CLOSED ).count() if open_minors > 0: potential_minors.append(PotentialFinding( check=f"{open_minors} open minor finding(s)", status="warning", recommendation="Address minor findings or have CAPA in progress", iso_reference="10.1" )) # Calculate scores total_checks = 10 passed_checks = total_checks - len(potential_majors) readiness_score = (passed_checks / total_checks) * 100 # Determine overall status certification_possible = len(potential_majors) == 0 if certification_possible: overall_status = "ready" if len(potential_minors) == 0 else "at_risk" else: overall_status = "not_ready" # Determine chapter statuses def get_chapter_status(has_major: bool, has_minor: bool) -> str: if has_major: return "fail" elif has_minor: return "warning" return "pass" chapter_4_majors = any("4." in (f.iso_reference or "") for f in potential_majors) chapter_5_majors = any("5." in (f.iso_reference or "") for f in potential_majors) chapter_6_majors = any("6." in (f.iso_reference or "") for f in potential_majors) chapter_9_majors = any("9." in (f.iso_reference or "") for f in potential_majors) chapter_10_majors = any("10." in (f.iso_reference or "") for f in potential_majors) # Priority actions priority_actions = [f.recommendation for f in potential_majors[:5]] # Save check result check = ISMSReadinessCheckDB( id=generate_id(), check_date=datetime.utcnow(), triggered_by=data.triggered_by, overall_status=overall_status, certification_possible=certification_possible, chapter_4_status=get_chapter_status(chapter_4_majors, False), chapter_5_status=get_chapter_status(chapter_5_majors, False), chapter_6_status=get_chapter_status(chapter_6_majors, False), chapter_7_status="pass", # Support - typically ok chapter_8_status="pass", # Operation - checked via controls chapter_9_status=get_chapter_status(chapter_9_majors, False), chapter_10_status=get_chapter_status(chapter_10_majors, False), potential_majors=[f.model_dump() for f in potential_majors], potential_minors=[f.model_dump() for f in potential_minors], improvement_opportunities=[f.model_dump() for f in improvement_opportunities], readiness_score=readiness_score, priority_actions=priority_actions ) db.add(check) db.commit() db.refresh(check) return ISMSReadinessCheckResponse( id=check.id, check_date=check.check_date, triggered_by=check.triggered_by, overall_status=check.overall_status, certification_possible=check.certification_possible, chapter_4_status=check.chapter_4_status, chapter_5_status=check.chapter_5_status, chapter_6_status=check.chapter_6_status, chapter_7_status=check.chapter_7_status, chapter_8_status=check.chapter_8_status, chapter_9_status=check.chapter_9_status, chapter_10_status=check.chapter_10_status, potential_majors=potential_majors, potential_minors=potential_minors, improvement_opportunities=improvement_opportunities, readiness_score=check.readiness_score, documentation_score=None, implementation_score=None, evidence_score=None, priority_actions=priority_actions ) @router.get("/readiness-check/latest", response_model=ISMSReadinessCheckResponse) async def get_latest_readiness_check(db: Session = Depends(get_db)): """Get the most recent readiness check result.""" check = db.query(ISMSReadinessCheckDB).order_by( ISMSReadinessCheckDB.check_date.desc() ).first() if not check: raise HTTPException(status_code=404, detail="No readiness check found. Run one first.") return check # ============================================================================= # AUDIT TRAIL # ============================================================================= @router.get("/audit-trail", response_model=AuditTrailResponse) async def get_audit_trail( entity_type: Optional[str] = None, entity_id: Optional[str] = None, performed_by: Optional[str] = None, action: Optional[str] = None, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), db: Session = Depends(get_db) ): """Query the audit trail with filters.""" query = db.query(AuditTrailDB) if entity_type: query = query.filter(AuditTrailDB.entity_type == entity_type) if entity_id: query = query.filter(AuditTrailDB.entity_id == entity_id) if performed_by: query = query.filter(AuditTrailDB.performed_by == performed_by) if action: query = query.filter(AuditTrailDB.action == action) total = query.count() entries = query.order_by(AuditTrailDB.performed_at.desc()).offset( (page - 1) * page_size ).limit(page_size).all() total_pages = (total + page_size - 1) // page_size return AuditTrailResponse( entries=entries, total=total, pagination=PaginationMeta( page=page, page_size=page_size, total=total, total_pages=total_pages, has_next=page < total_pages, has_prev=page > 1 ) ) # ============================================================================= # ISO 27001 OVERVIEW # ============================================================================= @router.get("/overview", response_model=ISO27001OverviewResponse) async def get_iso27001_overview(db: Session = Depends(get_db)): """ Get complete ISO 27001 compliance overview. Shows status of all chapters, key metrics, and readiness for certification. """ # Scope & SoA approval status scope = db.query(ISMSScopeDB).filter( ISMSScopeDB.status == ApprovalStatusEnum.APPROVED ).first() scope_approved = scope is not None soa_total = db.query(StatementOfApplicabilityDB).count() soa_approved = db.query(StatementOfApplicabilityDB).filter( StatementOfApplicabilityDB.approved_at != None ).count() soa_all_approved = soa_total > 0 and soa_approved == soa_total # Management Review & Internal Audit last_year = date.today().replace(year=date.today().year - 1) last_mgmt_review = db.query(ManagementReviewDB).filter( ManagementReviewDB.status == "approved" ).order_by(ManagementReviewDB.review_date.desc()).first() last_internal_audit = db.query(InternalAuditDB).filter( InternalAuditDB.status == "completed" ).order_by(InternalAuditDB.actual_end_date.desc()).first() # Findings open_majors = db.query(AuditFindingDB).filter( AuditFindingDB.finding_type == FindingTypeEnum.MAJOR, AuditFindingDB.status != FindingStatusEnum.CLOSED ).count() open_minors = db.query(AuditFindingDB).filter( AuditFindingDB.finding_type == FindingTypeEnum.MINOR, AuditFindingDB.status != FindingStatusEnum.CLOSED ).count() # Policies policies_total = db.query(ISMSPolicyDB).count() policies_approved = db.query(ISMSPolicyDB).filter( ISMSPolicyDB.status == ApprovalStatusEnum.APPROVED ).count() # Objectives objectives_total = db.query(SecurityObjectiveDB).count() objectives_achieved = db.query(SecurityObjectiveDB).filter( SecurityObjectiveDB.status == "achieved" ).count() # Calculate readiness readiness_factors = [ scope_approved, soa_all_approved, last_mgmt_review is not None and last_mgmt_review.review_date >= last_year, last_internal_audit is not None and (last_internal_audit.actual_end_date or date.min) >= last_year, open_majors == 0, policies_total > 0 and policies_approved >= policies_total * 0.8, objectives_total > 0 ] certification_readiness = sum(readiness_factors) / len(readiness_factors) * 100 # Overall status if open_majors > 0: overall_status = "not_ready" elif certification_readiness >= 80: overall_status = "ready" else: overall_status = "at_risk" # Build chapter status list chapters = [ ISO27001ChapterStatus( chapter="4", title="Kontext der Organisation", status="compliant" if scope_approved else "non_compliant", completion_percentage=100.0 if scope_approved else 0.0, open_findings=0, key_documents=["ISMS Scope", "Context Analysis"] if scope_approved else [], last_reviewed=scope.approved_at if scope else None ), ISO27001ChapterStatus( chapter="5", title="Führung", status="compliant" if policies_approved > 0 else "non_compliant", completion_percentage=(policies_approved / max(policies_total, 1)) * 100, open_findings=0, key_documents=[f"Policy {i+1}" for i in range(min(policies_approved, 3))], last_reviewed=None ), ISO27001ChapterStatus( chapter="6", title="Planung", status="compliant" if objectives_total > 0 else "partial", completion_percentage=75.0 if objectives_total > 0 else 25.0, open_findings=0, key_documents=["Risk Register", "Security Objectives"], last_reviewed=None ), ISO27001ChapterStatus( chapter="9", title="Bewertung der Leistung", status="compliant" if (last_mgmt_review and last_internal_audit) else "non_compliant", completion_percentage=100.0 if (last_mgmt_review and last_internal_audit) else 50.0, open_findings=open_majors + open_minors, key_documents=["Internal Audit Report", "Management Review Minutes"], last_reviewed=last_mgmt_review.approved_at if last_mgmt_review else None ), ISO27001ChapterStatus( chapter="10", title="Verbesserung", status="compliant" if open_majors == 0 else "non_compliant", completion_percentage=100.0 if open_majors == 0 else 50.0, open_findings=open_majors, key_documents=["CAPA Register"], last_reviewed=None ) ] return ISO27001OverviewResponse( overall_status=overall_status, certification_readiness=certification_readiness, chapters=chapters, scope_approved=scope_approved, soa_approved=soa_all_approved, last_management_review=last_mgmt_review.approved_at if last_mgmt_review else None, last_internal_audit=datetime.combine(last_internal_audit.actual_end_date, datetime.min.time()) if last_internal_audit and last_internal_audit.actual_end_date else None, open_major_findings=open_majors, open_minor_findings=open_minors, policies_count=policies_total, policies_approved=policies_approved, objectives_count=objectives_total, objectives_achieved=objectives_achieved )