Severity tells you how urgent an alert is; asset ownership tells you who should work it — and routing on only one of those sends critical cases to the wrong desk. This guide implements a routing rule that combines both deterministically, as one precise technique within alert routing workflows and the broader SOAR playbook & response automation discipline.
Root-Cause Context
A routing decision has two independent inputs that a single-axis rule collapses badly. Route on severity alone and every high-severity case lands in one shared tier-1 queue regardless of which team owns the affected system, so the finance-database team never sees the ransomware alert on their own asset until someone in the shared queue reassigns it. Route on owner alone and a low-severity informational alert pages the on-call at 3 a.m. with the same urgency as an active breach.
The correct rule is a two-dimensional decision: resolve the asset’s owning team from an authoritative inventory, then select the destination and urgency from the combination of that team and the severity band. A crown-jewel-owning team’s high-severity case goes straight to their senior on-call with a tight acknowledgement SLA; the same team’s low-severity case goes to their backlog; another team’s high-severity case goes to their on-call. Making the rule table-driven — team × severity band → destination — keeps it explainable and testable instead of a nest of conditionals.
Prerequisites
The rule targets Python 3.11+ and uses only the standard library plus pydantic. It resolves ownership from an inventory keyed by the ECS host.id, and plugs into the parent alert routing workflows dispatcher.
pip install "pydantic>=2.6,<3.0"
Production-Ready Implementation
The router resolves the owner, bands the severity, and looks up the destination in a two-dimensional table. A missing owner or an empty cell is a routing failure, never a silent default.
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
from pydantic import BaseModel, Field
logger = logging.getLogger("soc.soar.route.so")
class Case(BaseModel):
case_id: str = Field(min_length=1)
host_id: str = Field(min_length=1)
severity: int = Field(ge=0, le=100)
@dataclass(frozen=True)
class Destination:
queue: str
ack_seconds: int
def severity_band(severity: int) -> str:
if severity >= 80:
return "high"
if severity >= 40:
return "medium"
return "low"
# (team, band) -> Destination. Explicit; no cell defaults silently.
ROUTING: dict[tuple[str, str], Destination] = {
("crown_jewel", "high"): Destination("fin-ir-senior", 300),
("crown_jewel", "medium"): Destination("fin-ir-queue", 1800),
("crown_jewel", "low"): Destination("fin-ir-backlog", 86400),
("standard", "high"): Destination("soc-oncall", 900),
("standard", "medium"): Destination("soc-queue", 3600),
("standard", "low"): Destination("soc-backlog", 604800),
}
def route(case: Case, owner_team: Optional[str]) -> tuple[Optional[Destination], str]:
"""Return (destination, status). Fail closed on missing owner or cell."""
if owner_team is None:
logger.warning("ERR_ROUTE_001 no owner case=%s", case.case_id)
return None, "ERR_ROUTE_001"
band = severity_band(case.severity)
dest = ROUTING.get((owner_team, band))
if dest is None:
logger.warning("ERR_ROUTE_011 no cell team=%s band=%s", owner_team, band)
return None, "ERR_ROUTE_011"
logger.info('{"msg":"routed","case":"%s","team":"%s","band":"%s","dest":"%s"}',
case.case_id, owner_team, band, dest.queue)
return dest, "OK"
if __name__ == "__main__":
case = Case(case_id="C-3", host_id="FIN-DB-1", severity=90)
dest, status = route(case, owner_team="crown_jewel")
assert status == "OK" and dest.queue == "fin-ir-senior" and dest.ack_seconds == 300
Keying the table on the (team, band) tuple keeps the decision a single dictionary lookup and makes every routing outcome explainable: the logged team and band pinpoint exactly which cell fired. An unknown owner or an unpopulated cell returns a distinct error rather than falling through to a shared queue — the shared-queue default is precisely the failure this rule exists to prevent.
Error-Code Reference
| Code | Meaning | Action |
|---|---|---|
ERR_ROUTE_001 |
Asset owner unresolved from inventory | DLQ to manual triage; fix the CMDB entry |
ERR_ROUTE_011 |
No routing cell for the (team, band) combination | DLQ; add the cell — never add a catch-all default |
ERR_ROUTE_021 |
Acknowledgement SLA elapsed | Escalate per policy for that destination |
ERR_ROUTE_051 |
Owner resolved but team not in the routing table | DLQ; onboard the team into the table |
Operational Notes
- Both axes matter. Resolve the owner and band the severity; a one-dimensional rule mis-routes on the other axis.
- Every cell is explicit. Populate the full team × band grid; a missing cell is a routing failure that surfaces, not a silent fall-through to a shared queue.
- Log the winning cell. Record the team and band on the case so a mis-route is a one-line diagnosis, mirroring the recorded-decision discipline in the parent alert routing workflows guide.
- Tie SLAs to the cell. The acknowledgement deadline belongs in the destination, so escalation timers are driven by the same table that chose the queue.
Verification Checklist
FAQ
Why route on both severity and owner rather than just severity?
Because severity and ownership answer different questions — how urgent versus whose responsibility — and collapsing them loses one. Routing on severity alone sends every high-severity case to one shared queue, so the team that owns the affected system does not see their own critical alert until someone reassigns it. Combining both sends the right urgency to the right desk, which is the whole point of routing.
What if an asset's owning team is not in the routing table?
Treat it as an explicit failure (ERR_ROUTE_051) and send the case to manual triage, not a default queue. An owner that resolves but has no routing cell means the team was never onboarded into the routing table — a gap to fix by adding their row, not to paper over with a catch-all. Surfacing it forces the onboarding rather than silently burying the team’s cases in a shared bucket.