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,215 @@
"""Phase 9: Ownership service — techniques and detection assets."""
import logging
from datetime import datetime
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from app.models.ownership_queue import TechniqueOwnership
from app.models.detection_lifecycle import DetectionAsset
from app.models.technique import Technique
from app.domain.exceptions import EntityNotFoundError
from app.services import audit_service
logger = logging.getLogger(__name__)
def _now() -> datetime:
return datetime.utcnow()
# ── Technique Ownership ───────────────────────────────────────────────────────
def get_technique_ownership(db: Session, technique_id: UUID) -> Optional[TechniqueOwnership]:
return db.query(TechniqueOwnership).filter(
TechniqueOwnership.technique_id == technique_id
).first()
def set_technique_ownership(
db: Session,
technique_id: UUID,
owner_id: Optional[UUID],
backup_owner_id: Optional[UUID],
team: Optional[str],
notes: Optional[str],
assigned_by: UUID,
) -> TechniqueOwnership:
technique = db.query(Technique).filter(Technique.id == technique_id).first()
if not technique:
raise EntityNotFoundError("Technique", str(technique_id))
ownership = db.query(TechniqueOwnership).filter(
TechniqueOwnership.technique_id == technique_id
).first()
if not ownership:
ownership = TechniqueOwnership(technique_id=technique_id)
db.add(ownership)
ownership.owner_id = owner_id
ownership.backup_owner_id = backup_owner_id
ownership.team = team
ownership.notes = notes
ownership.assigned_at = _now()
ownership.assigned_by = assigned_by
ownership.updated_at = _now()
db.commit()
db.refresh(ownership)
audit_service.log_action(
db, assigned_by, "TECHNIQUE_OWNERSHIP_SET", "technique_ownership", str(ownership.id),
details={"technique_id": str(technique_id), "owner_id": str(owner_id) if owner_id else None, "team": team},
)
return ownership
def get_orphan_techniques(db: Session) -> list[dict]:
"""Techniques with no ownership record or null owner_id."""
owned_ids = db.query(TechniqueOwnership.technique_id).filter(
TechniqueOwnership.owner_id.isnot(None)
).subquery()
orphans = db.query(Technique).filter(
~Technique.id.in_(owned_ids)
).order_by(Technique.tactic, Technique.mitre_id).all()
return [
{
"technique_id": str(t.id),
"mitre_id": t.mitre_id,
"name": t.name,
"tactic": t.tactic,
}
for t in orphans
]
# ── Detection Asset Ownership ─────────────────────────────────────────────────
def set_asset_ownership(
db: Session,
asset_id: UUID,
owner_id: Optional[UUID],
backup_owner_id: Optional[UUID],
team: Optional[str],
user_id: UUID,
) -> DetectionAsset:
asset = db.query(DetectionAsset).filter(DetectionAsset.id == asset_id).first()
if not asset:
raise EntityNotFoundError("DetectionAsset", str(asset_id))
asset.owner_id = owner_id
asset.backup_owner_id = backup_owner_id
asset.team = team
db.commit()
db.refresh(asset)
audit_service.log_action(
db, user_id, "ASSET_OWNERSHIP_SET", "detection_asset", str(asset_id),
details={"owner_id": str(owner_id) if owner_id else None, "team": team},
)
return asset
def get_orphan_assets(db: Session) -> list[dict]:
"""Active detection assets with no owner."""
orphans = db.query(DetectionAsset).filter(
DetectionAsset.is_active == True,
DetectionAsset.owner_id.is_(None),
).order_by(DetectionAsset.platform, DetectionAsset.name).all()
return [
{
"asset_id": str(a.id),
"name": a.name,
"platform": a.platform,
"asset_type": a.asset_type,
"health_status": a.health_status.value if a.health_status else None,
}
for a in orphans
]
# ── Bulk Assignment ───────────────────────────────────────────────────────────
def bulk_assign_techniques_by_tactic(
db: Session,
tactic: str,
owner_id: Optional[UUID],
backup_owner_id: Optional[UUID],
team: Optional[str],
overwrite: bool,
user_id: UUID,
) -> dict:
techniques = db.query(Technique).filter(Technique.tactic == tactic).all()
assigned = 0
skipped = 0
now = _now()
for technique in techniques:
existing = db.query(TechniqueOwnership).filter(
TechniqueOwnership.technique_id == technique.id
).first()
if existing and existing.owner_id and not overwrite:
skipped += 1
continue
if not existing:
existing = TechniqueOwnership(technique_id=technique.id)
db.add(existing)
existing.owner_id = owner_id
existing.backup_owner_id = backup_owner_id
existing.team = team
existing.assigned_at = now
existing.assigned_by = user_id
existing.updated_at = now
assigned += 1
db.commit()
audit_service.log_action(
db, user_id, "BULK_OWNERSHIP_ASSIGNED", "technique_ownership", None,
details={"tactic": tactic, "assigned": assigned, "skipped": skipped, "team": team},
)
logger.info("Bulk ownership: tactic=%s assigned=%d skipped=%d", tactic, assigned, skipped)
return {"assigned_count": assigned, "skipped_count": skipped, "target_type": "technique"}
def bulk_assign_assets_by_platform(
db: Session,
platform: str,
owner_id: Optional[UUID],
backup_owner_id: Optional[UUID],
team: Optional[str],
overwrite: bool,
user_id: UUID,
) -> dict:
assets = db.query(DetectionAsset).filter(
DetectionAsset.platform == platform,
DetectionAsset.is_active == True,
).all()
assigned = 0
skipped = 0
for asset in assets:
if asset.owner_id and not overwrite:
skipped += 1
continue
asset.owner_id = owner_id
asset.backup_owner_id = backup_owner_id
asset.team = team
assigned += 1
db.commit()
audit_service.log_action(
db, user_id, "BULK_ASSET_OWNERSHIP_ASSIGNED", "detection_asset", None,
details={"platform": platform, "assigned": assigned, "skipped": skipped},
)
return {"assigned_count": assigned, "skipped_count": skipped, "target_type": "detection_asset"}

View File

@@ -0,0 +1,388 @@
"""Phase 9: Revalidation Queue service — generation, management, analyst dashboard."""
import logging
from datetime import datetime, timedelta
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from app.models.ownership_queue import (
RevalidationQueueItem, QueuePriority, QueueStatus, QueueReason,
TechniqueOwnership,
)
from app.models.detection_lifecycle import (
DetectionAsset, DetectionValidation, TechniqueConfidenceScore,
InfrastructureChangeLog, DetectionConfidence,
)
from app.models.technique import Technique
from app.domain.exceptions import EntityNotFoundError
logger = logging.getLogger(__name__)
def _now() -> datetime:
return datetime.utcnow()
# ── Queue Generation ──────────────────────────────────────────────────────────
def generate_queue_items(db: Session) -> dict:
"""
Scan the system and create new revalidation queue items for:
- Assets with no valid detection validation (validation_expired)
- Low-confidence techniques (low_confidence)
- Recent infrastructure changes that affected assets (infra_change)
Skips already-pending/in_progress items for the same asset+reason.
Returns counts of created and skipped items.
"""
created = 0
skipped = 0
now = _now()
def _has_active_item(technique_id=None, asset_id=None, reason=None) -> bool:
q = db.query(RevalidationQueueItem).filter(
RevalidationQueueItem.status.in_(["pending", "in_progress"])
)
if technique_id:
q = q.filter(RevalidationQueueItem.technique_id == technique_id)
if asset_id:
q = q.filter(RevalidationQueueItem.detection_asset_id == asset_id)
if reason:
q = q.filter(RevalidationQueueItem.reason == reason)
return q.first() is not None
# 1) Assets with no active valid validation
active_assets = db.query(DetectionAsset).filter(DetectionAsset.is_active == True).all()
for asset in active_assets:
has_valid = db.query(DetectionValidation).filter(
DetectionValidation.detection_asset_id == asset.id,
DetectionValidation.is_valid == True,
DetectionValidation.expires_at > now,
).first()
if has_valid:
continue
if _has_active_item(asset_id=asset.id, reason=QueueReason.validation_expired):
skipped += 1
continue
# Determine priority: assets not validated for >30 days are high
last_val = db.query(DetectionValidation).filter(
DetectionValidation.detection_asset_id == asset.id,
).order_by(DetectionValidation.validated_at.desc()).first()
if last_val is None:
priority = QueuePriority.high
detail = "Asset has never been validated"
else:
days_stale = (now - last_val.validated_at).days
if days_stale > 60:
priority = QueuePriority.critical
elif days_stale > 30:
priority = QueuePriority.high
else:
priority = QueuePriority.medium
detail = f"Last validation expired {days_stale} days ago"
item = RevalidationQueueItem(
detection_asset_id=asset.id,
priority=priority,
reason=QueueReason.validation_expired,
reason_detail=detail,
due_date=now + timedelta(days=7),
)
db.add(item)
created += 1
# 2) Low-confidence techniques (stale or broken)
low_scores = db.query(TechniqueConfidenceScore).filter(
TechniqueConfidenceScore.confidence_level.in_([
DetectionConfidence.stale, DetectionConfidence.broken,
])
).all()
for score in low_scores:
if _has_active_item(technique_id=score.technique_id, reason=QueueReason.low_confidence):
skipped += 1
continue
priority = (
QueuePriority.critical
if score.confidence_level == DetectionConfidence.broken
else QueuePriority.high
)
detail = (
f"Technique confidence {score.confidence_level.value} "
f"(score={score.confidence_score}). "
f"Risk: {', '.join(score.risk_factors or [])}"
)
item = RevalidationQueueItem(
technique_id=score.technique_id,
priority=priority,
reason=QueueReason.low_confidence,
reason_detail=detail,
due_date=now + timedelta(days=14),
)
db.add(item)
created += 1
# 3) Recent infrastructure changes (last 7 days) → one item per change
recent_changes = db.query(InfrastructureChangeLog).filter(
InfrastructureChangeLog.change_date >= now - timedelta(days=7),
InfrastructureChangeLog.auto_invalidate == True,
).all()
for change in recent_changes:
if _has_active_item(reason=QueueReason.infra_change) and False:
# Don't deduplicate infra changes — each change is separate
pass
existing = db.query(RevalidationQueueItem).filter(
RevalidationQueueItem.reason == QueueReason.infra_change,
RevalidationQueueItem.status.in_(["pending", "in_progress"]),
RevalidationQueueItem.extra["change_id"].astext == str(change.id),
).first()
if existing:
skipped += 1
continue
item = RevalidationQueueItem(
priority=QueuePriority.high,
reason=QueueReason.infra_change,
reason_detail=(
f"{change.change_type}: {change.description[:120]} "
f"({change.invalidated_count or 0} validations invalidated)"
),
due_date=now + timedelta(days=3),
extra={"change_id": str(change.id), "change_type": change.change_type,
"affected_platforms": change.affected_platforms or []},
)
db.add(item)
created += 1
if created > 0:
db.commit()
logger.info("Queue generation: created=%d skipped=%d", created, skipped)
return {"created": created, "skipped": skipped}
# ── Queue CRUD ────────────────────────────────────────────────────────────────
def list_queue(
db: Session,
status: Optional[str] = None,
priority: Optional[str] = None,
reason: Optional[str] = None,
assigned_to: Optional[UUID] = None,
technique_id: Optional[UUID] = None,
detection_asset_id: Optional[UUID] = None,
limit: int = 100,
offset: int = 0,
) -> list[RevalidationQueueItem]:
q = db.query(RevalidationQueueItem)
if status:
q = q.filter(RevalidationQueueItem.status == status)
if priority:
q = q.filter(RevalidationQueueItem.priority == priority)
if reason:
q = q.filter(RevalidationQueueItem.reason == reason)
if assigned_to:
q = q.filter(RevalidationQueueItem.assigned_to == assigned_to)
if technique_id:
q = q.filter(RevalidationQueueItem.technique_id == technique_id)
if detection_asset_id:
q = q.filter(RevalidationQueueItem.detection_asset_id == detection_asset_id)
# Priority order: critical > high > medium > low
priority_order = {
"critical": 0, "high": 1, "medium": 2, "low": 3,
}
from sqlalchemy import case
q = q.order_by(
case(
{"critical": 0, "high": 1, "medium": 2, "low": 3},
value=RevalidationQueueItem.priority,
),
RevalidationQueueItem.due_date.asc().nullslast(),
RevalidationQueueItem.created_at.asc(),
)
return q.offset(offset).limit(limit).all()
def create_queue_item(db: Session, data: dict, user_id: UUID) -> RevalidationQueueItem:
item = RevalidationQueueItem(
technique_id=data.get("technique_id"),
detection_asset_id=data.get("detection_asset_id"),
priority=data.get("priority", "medium"),
reason=data.get("reason", "manual"),
reason_detail=data.get("reason_detail"),
assigned_to=data.get("assigned_to"),
due_date=data.get("due_date"),
extra={"created_by": str(user_id)},
)
db.add(item)
db.commit()
db.refresh(item)
return item
def update_queue_item(db: Session, item_id: UUID, data: dict, user_id: UUID) -> RevalidationQueueItem:
item = db.query(RevalidationQueueItem).filter(RevalidationQueueItem.id == item_id).first()
if not item:
raise EntityNotFoundError("RevalidationQueueItem", str(item_id))
now = _now()
if "status" in data and data["status"] is not None:
new_status = data["status"]
item.status = new_status
if new_status == "completed":
item.completed_at = now
item.completed_by = user_id
elif new_status == "dismissed":
item.dismissed_at = now
if "assigned_to" in data:
item.assigned_to = data["assigned_to"]
if "priority" in data and data["priority"] is not None:
item.priority = data["priority"]
if "due_date" in data:
item.due_date = data["due_date"]
db.commit()
db.refresh(item)
return item
# ── Analyst Dashboard ─────────────────────────────────────────────────────────
def get_analyst_dashboard(db: Session, user_id: UUID) -> dict:
"""Return a personalised daily workday view for the logged-in analyst."""
now = _now()
# 1) My pending/in_progress queue items (assigned to me)
my_items = list_queue(db, status=None, assigned_to=user_id, limit=50)
my_items = [i for i in my_items if i.status in ("pending", "in_progress")]
# Also include unassigned items where I'm the technique/asset owner
owned_tech_ids = [
row.technique_id
for row in db.query(TechniqueOwnership.technique_id).filter(
(TechniqueOwnership.owner_id == user_id) |
(TechniqueOwnership.backup_owner_id == user_id)
).all()
]
owned_asset_ids = [
row.id
for row in db.query(DetectionAsset.id).filter(
DetectionAsset.is_active == True,
(DetectionAsset.owner_id == user_id) |
(DetectionAsset.backup_owner_id == user_id),
).all()
]
# Unassigned items for my techniques/assets
if owned_tech_ids or owned_asset_ids:
from sqlalchemy import or_
unassigned_q = db.query(RevalidationQueueItem).filter(
RevalidationQueueItem.status.in_(["pending", "in_progress"]),
RevalidationQueueItem.assigned_to.is_(None),
)
filters = []
if owned_tech_ids:
filters.append(RevalidationQueueItem.technique_id.in_(owned_tech_ids))
if owned_asset_ids:
filters.append(RevalidationQueueItem.detection_asset_id.in_(owned_asset_ids))
unassigned_q = unassigned_q.filter(or_(*filters))
unassigned_items = unassigned_q.limit(20).all()
# Merge, deduplicate by id
seen = {i.id for i in my_items}
for item in unassigned_items:
if item.id not in seen:
my_items.append(item)
seen.add(item.id)
# 2) Validations expiring in next 7 days on assets I own
expiring = []
if owned_asset_ids:
expiring_vals = db.query(DetectionValidation).filter(
DetectionValidation.detection_asset_id.in_(owned_asset_ids),
DetectionValidation.is_valid == True,
DetectionValidation.expires_at <= now + timedelta(days=7),
DetectionValidation.expires_at > now,
).order_by(DetectionValidation.expires_at.asc()).limit(20).all()
for v in expiring_vals:
asset = db.query(DetectionAsset).filter(DetectionAsset.id == v.detection_asset_id).first()
expiring.append({
"validation_id": str(v.id),
"asset_id": str(v.detection_asset_id),
"asset_name": asset.name if asset else None,
"expires_at": v.expires_at.isoformat() if v.expires_at else None,
"days_until_expiry": (v.expires_at - now).days if v.expires_at else None,
})
# 3) Recent infra changes (last 7 days)
recent_changes = db.query(InfrastructureChangeLog).filter(
InfrastructureChangeLog.change_date >= now - timedelta(days=7),
).order_by(InfrastructureChangeLog.change_date.desc()).limit(5).all()
infra_list = [
{
"change_id": str(c.id),
"change_type": c.change_type,
"description": c.description,
"affected_platforms": c.affected_platforms or [],
"invalidated_count": c.invalidated_count or 0,
"change_date": c.change_date.isoformat() if c.change_date else None,
}
for c in recent_changes
]
# 4) My techniques with low confidence
low_confidence = []
if owned_tech_ids:
low_scores = db.query(TechniqueConfidenceScore).filter(
TechniqueConfidenceScore.technique_id.in_(owned_tech_ids),
TechniqueConfidenceScore.confidence_score < 50,
).order_by(TechniqueConfidenceScore.confidence_score.asc()).limit(10).all()
for score in low_scores:
tech = db.query(Technique).filter(Technique.id == score.technique_id).first()
low_confidence.append({
"technique_id": str(score.technique_id),
"mitre_id": tech.mitre_id if tech else None,
"name": tech.name if tech else None,
"confidence_level": score.confidence_level.value if score.confidence_level else None,
"confidence_score": score.confidence_score,
"risk_factors": score.risk_factors or [],
})
# Summary
total_pending = db.query(RevalidationQueueItem).filter(
RevalidationQueueItem.status == QueueStatus.pending
).count()
total_critical = db.query(RevalidationQueueItem).filter(
RevalidationQueueItem.status.in_(["pending", "in_progress"]),
RevalidationQueueItem.priority == QueuePriority.critical,
).count()
return {
"my_pending_items": my_items,
"expiring_validations_7d": expiring,
"recent_infra_changes": infra_list,
"my_low_confidence_techniques": low_confidence,
"summary": {
"my_assigned_items": len([i for i in my_items if i.assigned_to == user_id]),
"my_owned_techniques": len(owned_tech_ids),
"my_owned_assets": len(owned_asset_ids),
"total_pending_system": total_pending,
"critical_system": total_critical,
"expiring_soon": len(expiring),
},
}