feat(ownership): Phase 9 — Ownership & Daily Operations [FASE-9]
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
216
backend/app/routers/ownership.py
Normal file
216
backend/app/routers/ownership.py
Normal 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"],
|
||||
}
|
||||
Reference in New Issue
Block a user