feat(ownership): Phase 9 — Ownership & Daily Operations [FASE-9]
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Backend:
- TechniqueOwnership model: per-technique owner, backup owner, team
- RevalidationQueueItem model: prioritised analyst work queue
  (critical/high/medium/low, reasons: validation_expired/infra_change/
   osint_alert/mitre_update/rule_modified/low_confidence/manual)
- Migration b035ownerq: creates technique_ownerships and
  revalidation_queue_items tables with full indexes

Services:
- ownership_service: set/get technique ownership, bulk assign by tactic
  or platform, orphan reports for techniques and assets
- revalidation_queue_service: smart queue generation (scans expired
  validations, low-confidence techniques, recent infra changes),
  list/create/update queue items, analyst dashboard

Router /api/v1/ownership:
  GET/PUT /ownership/techniques/{id}   — technique ownership
  PATCH   /ownership/assets/{id}       — asset ownership
  GET     /ownership/orphans/techniques — orphan report
  GET     /ownership/orphans/assets     — orphan report
  POST    /ownership/bulk-assign        — bulk by tactic/platform
  GET/POST /ownership/queue             — revalidation queue CRUD
  PATCH   /ownership/queue/{id}         — update item status/assignee
  POST    /ownership/queue/generate     — scan & generate items
  GET     /ownership/analyst-dashboard  — personalised daily view

Scheduler: queue_generation job daily at 02:30 (after decay engine)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-19 16:48:47 +02:00
parent 89a951c2a2
commit a8b4518485
9 changed files with 1233 additions and 0 deletions

View File

@@ -0,0 +1,216 @@
"""Phase 9: Ownership & Daily Operations router."""
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user, require_any_role
from app.domain.exceptions import EntityNotFoundError
from app.schemas.ownership_queue_schema import (
TechniqueOwnershipSet, TechniqueOwnershipOut,
DetectionAssetOwnershipPatch,
BulkAssignRequest, BulkAssignResult,
QueueItemCreate, QueueItemPatch, QueueItemOut,
AnalystDashboard,
)
from app.services import ownership_service, revalidation_queue_service
from app.models.ownership_queue import RevalidationQueueItem
router = APIRouter(prefix="/ownership", tags=["ownership"])
# ── Technique Ownership ───────────────────────────────────────────────────────
@router.get("/techniques/{technique_id}", response_model=TechniqueOwnershipOut)
def get_technique_ownership(
technique_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
ownership = ownership_service.get_technique_ownership(db, technique_id)
if not ownership:
raise EntityNotFoundError("TechniqueOwnership", str(technique_id))
return ownership
@router.put("/techniques/{technique_id}", response_model=TechniqueOwnershipOut)
def set_technique_ownership(
technique_id: UUID,
body: TechniqueOwnershipSet,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "blue_lead", "red_lead")),
):
return ownership_service.set_technique_ownership(
db, technique_id,
owner_id=body.owner_id,
backup_owner_id=body.backup_owner_id,
team=body.team,
notes=body.notes,
assigned_by=user.id,
)
# ── Detection Asset Ownership ─────────────────────────────────────────────────
@router.patch("/assets/{asset_id}", response_model=dict)
def set_asset_ownership(
asset_id: UUID,
body: DetectionAssetOwnershipPatch,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "blue_lead")),
):
ownership_service.set_asset_ownership(
db, asset_id,
owner_id=body.owner_id,
backup_owner_id=body.backup_owner_id,
team=body.team,
user_id=user.id,
)
return {"message": "Asset ownership updated"}
# ── Orphan Reports ────────────────────────────────────────────────────────────
@router.get("/orphans/techniques", response_model=list[dict])
def orphan_techniques(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Return techniques with no assigned owner."""
return ownership_service.get_orphan_techniques(db)
@router.get("/orphans/assets", response_model=list[dict])
def orphan_assets(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Return active detection assets with no assigned owner."""
return ownership_service.get_orphan_assets(db)
# ── Bulk Assignment ───────────────────────────────────────────────────────────
@router.post("/bulk-assign", response_model=BulkAssignResult)
def bulk_assign(
body: BulkAssignRequest,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "blue_lead", "red_lead")),
):
"""
Bulk-assign ownership.
- If `tactic` is set → assigns technique ownership for all techniques of that tactic.
- If `platform` is set → assigns asset ownership for all assets on that platform.
At least one of tactic/platform must be provided.
"""
if not body.tactic and not body.platform:
from fastapi import HTTPException
raise HTTPException(status_code=422, detail="Provide at least one of: tactic, platform")
if body.tactic:
result = ownership_service.bulk_assign_techniques_by_tactic(
db, body.tactic,
owner_id=body.owner_id,
backup_owner_id=body.backup_owner_id,
team=body.team,
overwrite=body.overwrite,
user_id=user.id,
)
else:
result = ownership_service.bulk_assign_assets_by_platform(
db, body.platform,
owner_id=body.owner_id,
backup_owner_id=body.backup_owner_id,
team=body.team,
overwrite=body.overwrite,
user_id=user.id,
)
return BulkAssignResult(**result)
# ── Revalidation Queue ────────────────────────────────────────────────────────
@router.get("/queue", response_model=list[QueueItemOut])
def list_queue(
status: Optional[str] = Query(None),
priority: Optional[str] = Query(None),
reason: Optional[str] = Query(None),
assigned_to: Optional[UUID] = Query(None),
technique_id: Optional[UUID] = Query(None),
detection_asset_id: Optional[UUID] = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return revalidation_queue_service.list_queue(
db, status=status, priority=priority, reason=reason,
assigned_to=assigned_to, technique_id=technique_id,
detection_asset_id=detection_asset_id, limit=limit, offset=offset,
)
@router.post("/queue", response_model=QueueItemOut, status_code=201)
def create_queue_item(
body: QueueItemCreate,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return revalidation_queue_service.create_queue_item(db, body.model_dump(), user.id)
@router.patch("/queue/{item_id}", response_model=QueueItemOut)
def update_queue_item(
item_id: UUID,
body: QueueItemPatch,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return revalidation_queue_service.update_queue_item(db, item_id, body.model_dump(exclude_unset=True), user.id)
@router.post("/queue/generate", response_model=dict)
def generate_queue(
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "blue_lead")),
):
"""Scan the system and create new revalidation queue items."""
return revalidation_queue_service.generate_queue_items(db)
# ── Analyst Dashboard ─────────────────────────────────────────────────────────
@router.get("/analyst-dashboard")
def analyst_dashboard(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Personalised daily workday view: my queue, expiring validations, infra changes, low-confidence techniques."""
dashboard = revalidation_queue_service.get_analyst_dashboard(db, user.id)
# Serialize queue items to dicts (ORM objects → plain dicts)
def _item_to_dict(item: RevalidationQueueItem) -> dict:
return {
"id": str(item.id),
"technique_id": str(item.technique_id) if item.technique_id else None,
"detection_asset_id": str(item.detection_asset_id) if item.detection_asset_id else None,
"priority": item.priority.value if hasattr(item.priority, "value") else item.priority,
"reason": item.reason.value if hasattr(item.reason, "value") else item.reason,
"reason_detail": item.reason_detail,
"status": item.status.value if hasattr(item.status, "value") else item.status,
"assigned_to": str(item.assigned_to) if item.assigned_to else None,
"due_date": item.due_date.isoformat() if item.due_date else None,
"created_at": item.created_at.isoformat() if item.created_at else None,
}
return {
"my_pending_items": [_item_to_dict(i) for i in dashboard["my_pending_items"]],
"expiring_validations_7d": dashboard["expiring_validations_7d"],
"recent_infra_changes": dashboard["recent_infra_changes"],
"my_low_confidence_techniques": dashboard["my_low_confidence_techniques"],
"summary": dashboard["summary"],
}