Phase 1 Step 5 of PHASE1_RUNBOOK.md.
compliance/db/repository.py (1547 LOC) decomposed into seven sibling
per-aggregate repository modules:
regulation_repository.py (268) — Regulation + Requirement
control_repository.py (291) — Control + ControlMapping
evidence_repository.py (143)
risk_repository.py (148)
audit_export_repository.py (110)
service_module_repository.py (247)
audit_session_repository.py (478) — AuditSession + AuditSignOff
compliance/db/isms_repository.py (838 LOC) decomposed into two
sub-aggregate modules mirroring the models split:
isms_governance_repository.py (354) — Scope, Policy, Objective, SoA
isms_audit_repository.py (499) — Finding, CAPA, Review, Internal Audit,
Trail, Readiness
Both original files become thin re-export shims (37 and 25 LOC
respectively) so every existing import continues to work unchanged.
New code SHOULD import from the aggregate module directly.
All new sibling files under the 500-line hard cap; largest is
isms_audit_repository.py at 499 (on the edge; when Phase 1 Step 4
router->service extraction lands, the audit_session repo may split
further if growth exceeds 500).
Verified:
- 173/173 pytest compliance/tests/ tests/contracts/ pass
- OpenAPI 360 paths / 484 operations unchanged
- All repo files under 500 LOC
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
500 lines
17 KiB
Python
500 lines
17 KiB
Python
"""
|
|
ISMS repositories — extracted from compliance/db/isms_repository.py.
|
|
|
|
Phase 1 Step 5: split per sub-aggregate. Re-exported from
|
|
``compliance.db.isms_repository`` for backwards compatibility.
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import datetime, date, timezone
|
|
from typing import List, Optional, Dict, Any, Tuple
|
|
|
|
from sqlalchemy.orm import Session as DBSession
|
|
|
|
from compliance.db.models import (
|
|
ISMSScopeDB, ISMSPolicyDB, SecurityObjectiveDB,
|
|
StatementOfApplicabilityDB, AuditFindingDB, CorrectiveActionDB,
|
|
ManagementReviewDB, InternalAuditDB, AuditTrailDB, ISMSReadinessCheckDB,
|
|
ApprovalStatusEnum, FindingTypeEnum, FindingStatusEnum, CAPATypeEnum,
|
|
)
|
|
|
|
class AuditFindingRepository:
|
|
"""Repository for Audit Findings (Major/Minor/OFI)."""
|
|
|
|
def __init__(self, db: DBSession):
|
|
self.db = db
|
|
|
|
def create(
|
|
self,
|
|
finding_type: FindingTypeEnum,
|
|
title: str,
|
|
description: str,
|
|
auditor: str,
|
|
iso_chapter: Optional[str] = None,
|
|
annex_a_control: Optional[str] = None,
|
|
objective_evidence: Optional[str] = None,
|
|
owner: Optional[str] = None,
|
|
due_date: Optional[date] = None,
|
|
internal_audit_id: Optional[str] = None,
|
|
) -> AuditFindingDB:
|
|
"""Create a new audit finding."""
|
|
# Generate finding ID
|
|
year = date.today().year
|
|
existing_count = self.db.query(AuditFindingDB).filter(
|
|
AuditFindingDB.finding_id.like(f"FIND-{year}-%")
|
|
).count()
|
|
finding_id = f"FIND-{year}-{existing_count + 1:03d}"
|
|
|
|
finding = AuditFindingDB(
|
|
id=str(uuid.uuid4()),
|
|
finding_id=finding_id,
|
|
finding_type=finding_type,
|
|
iso_chapter=iso_chapter,
|
|
annex_a_control=annex_a_control,
|
|
title=title,
|
|
description=description,
|
|
objective_evidence=objective_evidence,
|
|
owner=owner,
|
|
auditor=auditor,
|
|
due_date=due_date,
|
|
internal_audit_id=internal_audit_id,
|
|
status=FindingStatusEnum.OPEN,
|
|
)
|
|
self.db.add(finding)
|
|
self.db.commit()
|
|
self.db.refresh(finding)
|
|
return finding
|
|
|
|
def get_by_id(self, finding_id: str) -> Optional[AuditFindingDB]:
|
|
"""Get finding by UUID or finding_id."""
|
|
return self.db.query(AuditFindingDB).filter(
|
|
(AuditFindingDB.id == finding_id) | (AuditFindingDB.finding_id == finding_id)
|
|
).first()
|
|
|
|
def get_all(
|
|
self,
|
|
finding_type: Optional[FindingTypeEnum] = None,
|
|
status: Optional[FindingStatusEnum] = None,
|
|
internal_audit_id: Optional[str] = None,
|
|
) -> List[AuditFindingDB]:
|
|
"""Get all findings with optional filters."""
|
|
query = self.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)
|
|
return query.order_by(AuditFindingDB.identified_date.desc()).all()
|
|
|
|
def get_open_majors(self) -> List[AuditFindingDB]:
|
|
"""Get all open major findings (blocking certification)."""
|
|
return self.db.query(AuditFindingDB).filter(
|
|
AuditFindingDB.finding_type == FindingTypeEnum.MAJOR,
|
|
AuditFindingDB.status != FindingStatusEnum.CLOSED
|
|
).all()
|
|
|
|
def get_statistics(self) -> Dict[str, Any]:
|
|
"""Get finding statistics."""
|
|
findings = self.get_all()
|
|
|
|
return {
|
|
"total": len(findings),
|
|
"major": sum(1 for f in findings if f.finding_type == FindingTypeEnum.MAJOR),
|
|
"minor": sum(1 for f in findings if f.finding_type == FindingTypeEnum.MINOR),
|
|
"ofi": sum(1 for f in findings if f.finding_type == FindingTypeEnum.OFI),
|
|
"positive": sum(1 for f in findings if f.finding_type == FindingTypeEnum.POSITIVE),
|
|
"open": sum(1 for f in findings if f.status != FindingStatusEnum.CLOSED),
|
|
"closed": sum(1 for f in findings if f.status == FindingStatusEnum.CLOSED),
|
|
"blocking_certification": sum(
|
|
1 for f in findings
|
|
if f.finding_type == FindingTypeEnum.MAJOR and f.status != FindingStatusEnum.CLOSED
|
|
),
|
|
}
|
|
|
|
def close(
|
|
self,
|
|
finding_id: str,
|
|
closed_by: str,
|
|
closure_notes: str,
|
|
verification_method: Optional[str] = None,
|
|
verification_evidence: Optional[str] = None,
|
|
) -> Optional[AuditFindingDB]:
|
|
"""Close a finding after verification."""
|
|
finding = self.get_by_id(finding_id)
|
|
if not finding:
|
|
return None
|
|
|
|
finding.status = FindingStatusEnum.CLOSED
|
|
finding.closed_date = date.today()
|
|
finding.closed_by = closed_by
|
|
finding.closure_notes = closure_notes
|
|
finding.verification_method = verification_method
|
|
finding.verification_evidence = verification_evidence
|
|
finding.verified_by = closed_by
|
|
finding.verified_at = datetime.now(timezone.utc)
|
|
|
|
self.db.commit()
|
|
self.db.refresh(finding)
|
|
return finding
|
|
|
|
|
|
class CorrectiveActionRepository:
|
|
"""Repository for Corrective/Preventive Actions (CAPA)."""
|
|
|
|
def __init__(self, db: DBSession):
|
|
self.db = db
|
|
|
|
def create(
|
|
self,
|
|
finding_id: str,
|
|
capa_type: CAPATypeEnum,
|
|
title: str,
|
|
description: str,
|
|
assigned_to: str,
|
|
planned_completion: date,
|
|
expected_outcome: Optional[str] = None,
|
|
effectiveness_criteria: Optional[str] = None,
|
|
) -> CorrectiveActionDB:
|
|
"""Create a new CAPA."""
|
|
# Generate CAPA ID
|
|
year = date.today().year
|
|
existing_count = self.db.query(CorrectiveActionDB).filter(
|
|
CorrectiveActionDB.capa_id.like(f"CAPA-{year}-%")
|
|
).count()
|
|
capa_id = f"CAPA-{year}-{existing_count + 1:03d}"
|
|
|
|
capa = CorrectiveActionDB(
|
|
id=str(uuid.uuid4()),
|
|
capa_id=capa_id,
|
|
finding_id=finding_id,
|
|
capa_type=capa_type,
|
|
title=title,
|
|
description=description,
|
|
expected_outcome=expected_outcome,
|
|
assigned_to=assigned_to,
|
|
planned_completion=planned_completion,
|
|
effectiveness_criteria=effectiveness_criteria,
|
|
status="planned",
|
|
)
|
|
self.db.add(capa)
|
|
|
|
# Update finding status
|
|
finding = self.db.query(AuditFindingDB).filter(AuditFindingDB.id == finding_id).first()
|
|
if finding:
|
|
finding.status = FindingStatusEnum.CORRECTIVE_ACTION_PENDING
|
|
|
|
self.db.commit()
|
|
self.db.refresh(capa)
|
|
return capa
|
|
|
|
def get_by_id(self, capa_id: str) -> Optional[CorrectiveActionDB]:
|
|
"""Get CAPA by UUID or capa_id."""
|
|
return self.db.query(CorrectiveActionDB).filter(
|
|
(CorrectiveActionDB.id == capa_id) | (CorrectiveActionDB.capa_id == capa_id)
|
|
).first()
|
|
|
|
def get_by_finding(self, finding_id: str) -> List[CorrectiveActionDB]:
|
|
"""Get all CAPAs for a finding."""
|
|
return self.db.query(CorrectiveActionDB).filter(
|
|
CorrectiveActionDB.finding_id == finding_id
|
|
).order_by(CorrectiveActionDB.planned_completion).all()
|
|
|
|
def verify(
|
|
self,
|
|
capa_id: str,
|
|
verified_by: str,
|
|
is_effective: bool,
|
|
effectiveness_notes: Optional[str] = None,
|
|
) -> Optional[CorrectiveActionDB]:
|
|
"""Verify a completed CAPA."""
|
|
capa = self.get_by_id(capa_id)
|
|
if not capa:
|
|
return None
|
|
|
|
capa.effectiveness_verified = is_effective
|
|
capa.effectiveness_verification_date = date.today()
|
|
capa.effectiveness_notes = effectiveness_notes
|
|
capa.status = "verified" if is_effective else "completed"
|
|
|
|
# If verified, check if all CAPAs for finding are verified
|
|
if is_effective:
|
|
finding = self.db.query(AuditFindingDB).filter(
|
|
AuditFindingDB.id == capa.finding_id
|
|
).first()
|
|
if finding:
|
|
unverified = self.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
|
|
|
|
self.db.commit()
|
|
self.db.refresh(capa)
|
|
return capa
|
|
|
|
|
|
class ManagementReviewRepository:
|
|
"""Repository for Management Reviews (ISO 27001 Chapter 9.3)."""
|
|
|
|
def __init__(self, db: DBSession):
|
|
self.db = db
|
|
|
|
def create(
|
|
self,
|
|
title: str,
|
|
review_date: date,
|
|
chairperson: str,
|
|
review_period_start: Optional[date] = None,
|
|
review_period_end: Optional[date] = None,
|
|
) -> ManagementReviewDB:
|
|
"""Create a new management review."""
|
|
# Generate review ID
|
|
year = review_date.year
|
|
quarter = (review_date.month - 1) // 3 + 1
|
|
review_id = f"MR-{year}-Q{quarter}"
|
|
|
|
# Check for duplicate
|
|
existing = self.db.query(ManagementReviewDB).filter(
|
|
ManagementReviewDB.review_id == review_id
|
|
).first()
|
|
if existing:
|
|
review_id = f"{review_id}-{str(uuid.uuid4())[:4]}"
|
|
|
|
review = ManagementReviewDB(
|
|
id=str(uuid.uuid4()),
|
|
review_id=review_id,
|
|
title=title,
|
|
review_date=review_date,
|
|
review_period_start=review_period_start,
|
|
review_period_end=review_period_end,
|
|
chairperson=chairperson,
|
|
status="draft",
|
|
)
|
|
self.db.add(review)
|
|
self.db.commit()
|
|
self.db.refresh(review)
|
|
return review
|
|
|
|
def get_by_id(self, review_id: str) -> Optional[ManagementReviewDB]:
|
|
"""Get review by UUID or review_id."""
|
|
return self.db.query(ManagementReviewDB).filter(
|
|
(ManagementReviewDB.id == review_id) | (ManagementReviewDB.review_id == review_id)
|
|
).first()
|
|
|
|
def get_latest_approved(self) -> Optional[ManagementReviewDB]:
|
|
"""Get the most recent approved management review."""
|
|
return self.db.query(ManagementReviewDB).filter(
|
|
ManagementReviewDB.status == "approved"
|
|
).order_by(ManagementReviewDB.review_date.desc()).first()
|
|
|
|
def approve(
|
|
self,
|
|
review_id: str,
|
|
approved_by: str,
|
|
next_review_date: date,
|
|
minutes_document_path: Optional[str] = None,
|
|
) -> Optional[ManagementReviewDB]:
|
|
"""Approve a management review."""
|
|
review = self.get_by_id(review_id)
|
|
if not review:
|
|
return None
|
|
|
|
review.status = "approved"
|
|
review.approved_by = approved_by
|
|
review.approved_at = datetime.now(timezone.utc)
|
|
review.next_review_date = next_review_date
|
|
review.minutes_document_path = minutes_document_path
|
|
|
|
self.db.commit()
|
|
self.db.refresh(review)
|
|
return review
|
|
|
|
|
|
class InternalAuditRepository:
|
|
"""Repository for Internal Audits (ISO 27001 Chapter 9.2)."""
|
|
|
|
def __init__(self, db: DBSession):
|
|
self.db = db
|
|
|
|
def create(
|
|
self,
|
|
title: str,
|
|
audit_type: str,
|
|
planned_date: date,
|
|
lead_auditor: str,
|
|
scope_description: Optional[str] = None,
|
|
iso_chapters_covered: Optional[List[str]] = None,
|
|
annex_a_controls_covered: Optional[List[str]] = None,
|
|
) -> InternalAuditDB:
|
|
"""Create a new internal audit."""
|
|
# Generate audit ID
|
|
year = planned_date.year
|
|
existing_count = self.db.query(InternalAuditDB).filter(
|
|
InternalAuditDB.audit_id.like(f"IA-{year}-%")
|
|
).count()
|
|
audit_id = f"IA-{year}-{existing_count + 1:03d}"
|
|
|
|
audit = InternalAuditDB(
|
|
id=str(uuid.uuid4()),
|
|
audit_id=audit_id,
|
|
title=title,
|
|
audit_type=audit_type,
|
|
scope_description=scope_description,
|
|
iso_chapters_covered=iso_chapters_covered,
|
|
annex_a_controls_covered=annex_a_controls_covered,
|
|
planned_date=planned_date,
|
|
lead_auditor=lead_auditor,
|
|
status="planned",
|
|
)
|
|
self.db.add(audit)
|
|
self.db.commit()
|
|
self.db.refresh(audit)
|
|
return audit
|
|
|
|
def get_by_id(self, audit_id: str) -> Optional[InternalAuditDB]:
|
|
"""Get audit by UUID or audit_id."""
|
|
return self.db.query(InternalAuditDB).filter(
|
|
(InternalAuditDB.id == audit_id) | (InternalAuditDB.audit_id == audit_id)
|
|
).first()
|
|
|
|
def get_latest_completed(self) -> Optional[InternalAuditDB]:
|
|
"""Get the most recent completed internal audit."""
|
|
return self.db.query(InternalAuditDB).filter(
|
|
InternalAuditDB.status == "completed"
|
|
).order_by(InternalAuditDB.actual_end_date.desc()).first()
|
|
|
|
def complete(
|
|
self,
|
|
audit_id: str,
|
|
audit_conclusion: str,
|
|
overall_assessment: str,
|
|
follow_up_audit_required: bool = False,
|
|
) -> Optional[InternalAuditDB]:
|
|
"""Complete an internal audit."""
|
|
audit = self.get_by_id(audit_id)
|
|
if not audit:
|
|
return None
|
|
|
|
audit.status = "completed"
|
|
audit.actual_end_date = date.today()
|
|
audit.report_date = date.today()
|
|
audit.audit_conclusion = audit_conclusion
|
|
audit.overall_assessment = overall_assessment
|
|
audit.follow_up_audit_required = follow_up_audit_required
|
|
|
|
self.db.commit()
|
|
self.db.refresh(audit)
|
|
return audit
|
|
|
|
|
|
class AuditTrailRepository:
|
|
"""Repository for Audit Trail entries."""
|
|
|
|
def __init__(self, db: DBSession):
|
|
self.db = db
|
|
|
|
def log(
|
|
self,
|
|
entity_type: str,
|
|
entity_id: str,
|
|
entity_name: str,
|
|
action: str,
|
|
performed_by: str,
|
|
field_changed: Optional[str] = None,
|
|
old_value: Optional[str] = None,
|
|
new_value: Optional[str] = None,
|
|
change_summary: Optional[str] = None,
|
|
) -> AuditTrailDB:
|
|
"""Log an audit trail entry."""
|
|
import hashlib
|
|
entry = AuditTrailDB(
|
|
id=str(uuid.uuid4()),
|
|
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.now(timezone.utc),
|
|
checksum=hashlib.sha256(
|
|
f"{entity_type}|{entity_id}|{action}|{performed_by}".encode()
|
|
).hexdigest(),
|
|
)
|
|
self.db.add(entry)
|
|
self.db.commit()
|
|
self.db.refresh(entry)
|
|
return entry
|
|
|
|
def get_by_entity(
|
|
self,
|
|
entity_type: str,
|
|
entity_id: str,
|
|
limit: int = 100,
|
|
) -> List[AuditTrailDB]:
|
|
"""Get audit trail for a specific entity."""
|
|
return self.db.query(AuditTrailDB).filter(
|
|
AuditTrailDB.entity_type == entity_type,
|
|
AuditTrailDB.entity_id == entity_id
|
|
).order_by(AuditTrailDB.performed_at.desc()).limit(limit).all()
|
|
|
|
def get_paginated(
|
|
self,
|
|
page: int = 1,
|
|
page_size: int = 50,
|
|
entity_type: Optional[str] = None,
|
|
entity_id: Optional[str] = None,
|
|
performed_by: Optional[str] = None,
|
|
action: Optional[str] = None,
|
|
) -> Tuple[List[AuditTrailDB], int]:
|
|
"""Get paginated audit trail with filters."""
|
|
query = self.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()
|
|
|
|
return entries, total
|
|
|
|
|
|
class ISMSReadinessCheckRepository:
|
|
"""Repository for ISMS Readiness Check results."""
|
|
|
|
def __init__(self, db: DBSession):
|
|
self.db = db
|
|
|
|
def save(self, check: ISMSReadinessCheckDB) -> ISMSReadinessCheckDB:
|
|
"""Save a readiness check result."""
|
|
self.db.add(check)
|
|
self.db.commit()
|
|
self.db.refresh(check)
|
|
return check
|
|
|
|
def get_latest(self) -> Optional[ISMSReadinessCheckDB]:
|
|
"""Get the most recent readiness check."""
|
|
return self.db.query(ISMSReadinessCheckDB).order_by(
|
|
ISMSReadinessCheckDB.check_date.desc()
|
|
).first()
|
|
|
|
def get_history(self, limit: int = 10) -> List[ISMSReadinessCheckDB]:
|
|
"""Get readiness check history."""
|
|
return self.db.query(ISMSReadinessCheckDB).order_by(
|
|
ISMSReadinessCheckDB.check_date.desc()
|
|
).limit(limit).all()
|
|
|