admin / Synapse-Cortex
publicSelf Hosted ITSM Tool with RBAC/Tenanting and MFA
Synapse-Cortex / Synapse-Cortexv2 / app / routers / ingest.py
9555 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 | import hashlib import secrets from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.orm import Session from .. import models, schemas from ..audit import log_audit from ..database import get_db from ..queries import apply_sla, next_ticket_number from ..utils import match_asset_type_name router = APIRouter(prefix="/api/ingest", tags=["ingest"]) def _hash_key(raw: str) -> str: return hashlib.sha256(raw.encode("utf-8")).hexdigest() def _resolve_integration_key(db: Session, raw_token: str) -> Optional[models.IntegrationKey]: """Map a presented bearer token back to its tenant's key row. Only enabled keys authenticate. Mirrors NetscanXi's own outbound-key resolution: hash the presented token and compare in constant time against every enabled key (there's one active key per tenant, so this table is small).""" if not raw_token: return None wanted = _hash_key(raw_token) for key_row in db.query(models.IntegrationKey).filter(models.IntegrationKey.enabled.is_(True)).all(): if secrets.compare_digest(key_row.key_hash, wanted): return key_row return None def _authenticate(request: Request, db: Session) -> models.IntegrationKey: auth_header = request.headers.get("Authorization", "") scheme, _, token = auth_header.partition(" ") if scheme.lower() != "bearer" or not token: raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Missing or malformed bearer token") key_row = _resolve_integration_key(db, token) if not key_row: raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid or disabled API key") return key_row def _upsert_asset(db: Session, tenant_id, item, type_id_by_name, fallback_type_id) -> str: """Create or update ONE asset. Returns "created" | "updated" | "skipped". Raises on a DB-level problem (e.g. a value that violates a column) so the caller can isolate it in a savepoint instead of failing the whole push.""" mac = item.mac.strip().lower().replace("-", ":") if item.mac else None ip = item.ip.strip() if item.ip else None software = [{"name": s.name, "version": s.version} for s in (item.software or [])] # Match an existing record: first by NetscanXi asset_id (repeat import of a # known device), else by MAC within this tenant (device NetscanXi re-IDed). existing = ( db.query(models.Asset) .filter(models.Asset.tenant_id == tenant_id, models.Asset.asset_id == item.asset_id) .first() ) if not existing and mac: existing = ( db.query(models.Asset) .filter(models.Asset.tenant_id == tenant_id, models.Asset.mac_address == mac) .first() ) if existing: # Only write fields that differ; re-pushing identical scan data leaves # the row (and its updated_at) untouched and is reported as skipped. new_values = { "asset_id": item.asset_id, "mac_address": mac, "ip_address": ip, "os": item.os, "software": software or None, "netscanxi_device_type": item.device_type, "source": models.AssetSource.NETSCANXI, } if item.hostname: new_values["name"] = item.hostname changed = False for field, new_value in new_values.items(): if getattr(existing, field) != new_value: setattr(existing, field, new_value) changed = True return "updated" if changed else "skipped" matched_name = match_asset_type_name(item.device_type, type_id_by_name.keys()) asset = models.Asset( tenant_id=tenant_id, asset_id=item.asset_id, name=item.hostname or item.asset_id, asset_type_id=type_id_by_name.get(matched_name) or fallback_type_id, mac_address=mac, ip_address=ip, source=models.AssetSource.NETSCANXI, os=item.os, software=software or None, netscanxi_device_type=item.device_type, ) db.add(asset) return "created" @router.post("/assets") def ingest_assets( request: Request, payload: schemas.IngestRequest, db: Session = Depends(get_db), ): key_row = _authenticate(request, db) # Adopts the same 401/422/200 test-connection contract NetscanXi's Sonar # integration already uses: authenticate first, THEN validate the payload, # so a connectivity probe with an empty list can distinguish "bad key" # from "reached the right place" without ever writing data. if not payload.assets: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="assets list is empty") tenant_id = key_row.tenant_id created = 0 updated = 0 skipped: list[str] = [] type_options = ( db.query(models.AssetTypeOption).filter(models.AssetTypeOption.tenant_id == tenant_id).all() ) type_id_by_name = {option.name: option.id for option in type_options} # Resilience: Asset.asset_type_id is NOT NULL, so a tenant with no # AssetTypeOption rows (e.g. options were never seeded at setup) would make # every new-asset insert fail with an IntegrityError and 500 the whole push. # Guarantee a fallback "Other" option exists to bind new assets to. if not type_id_by_name: fallback = models.AssetTypeOption(tenant_id=tenant_id, name="Other") db.add(fallback) db.flush() type_id_by_name[fallback.name] = fallback.id fallback_type_id = type_id_by_name.get("Other") or next(iter(type_id_by_name.values())) errors: list[dict] = [] for item in payload.assets: outcome = None try: # Isolate each asset in a SAVEPOINT so one problematic row (e.g. a # value that violates a column constraint) reports its error instead # of 500ing the entire push. flush() forces the write to happen here. with db.begin_nested(): outcome = _upsert_asset(db, tenant_id, item, type_id_by_name, fallback_type_id) db.flush() except Exception as e: # noqa: BLE001 - report, don't abort the batch errors.append({"asset_id": item.asset_id, "error": str(e)[:300]}) continue if outcome == "created": created += 1 elif outcome == "updated": updated += 1 else: skipped.append(item.asset_id) key_row.last_used_at = datetime.now(timezone.utc) log_audit( db, tenant_id=tenant_id, user_id=None, action="asset_import", detail={"count": len(payload.assets), "created": created, "updated": updated, "skipped": len(skipped), "errors": len(errors)}, ) db.commit() return {"ok": True, "created": created, "updated": updated, "skipped": skipped, "errors": errors} @router.post("/tickets") def ingest_tickets( request: Request, payload: schemas.IngestTicketsRequest, db: Session = Depends(get_db), ): """NetscanXi pushes its Remediation Tracking items here to auto-create Cortex tickets. Uses the same API key as asset import - one key grants both capabilities for a tenant. Dedup is create-only: a ticket is created once per external_ref (e.g. NetscanXi's own remediation item id) and never touched again on subsequent pushes, so an agent's work on it in Cortex is never silently overwritten. Re-pushing an already-known external_ref is reported back as "skipped", not an error. """ key_row = _authenticate(request, db) if not payload.tickets: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="tickets list is empty") tenant_id = key_row.tenant_id created = 0 skipped: list[str] = [] for item in payload.tickets: existing = ( db.query(models.Ticket) .filter(models.Ticket.tenant_id == tenant_id, models.Ticket.external_ref == item.external_ref) .first() ) if existing: skipped.append(item.external_ref) continue asset = None if item.asset_id: asset = ( db.query(models.Asset) .filter(models.Asset.tenant_id == tenant_id, models.Asset.asset_id == item.asset_id) .first() ) ticket = models.Ticket( tenant_id=tenant_id, ticket_number=next_ticket_number(db, tenant_id), title=item.title, description=item.description, ticket_type=models.TicketType.INCIDENT, priority=item.priority, status=item.status, asset_id=asset.id if asset else None, source=models.TicketSource.NETSCANXI, external_ref=item.external_ref, ) db.add(ticket) db.flush() apply_sla(db, ticket) if item.status in (models.TicketStatus.RESOLVED, models.TicketStatus.CLOSED): ticket.resolved_at = datetime.now(timezone.utc) created += 1 key_row.last_used_at = datetime.now(timezone.utc) log_audit( db, tenant_id=tenant_id, user_id=None, action="ticket_import", detail={"count": len(payload.tickets), "created": created, "skipped": len(skipped)}, ) db.commit() return {"ok": True, "created": created, "skipped": skipped} |