Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .gitignore
Binary file not shown.
7 changes: 7 additions & 0 deletions backend/app/anomaly_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,11 @@ def analyze_reading(reading: models.MeterReading, db: Session) -> models.Anomaly
db.commit()
db.refresh(anomaly)

<<<<<<< HEAD
# Auto-generate notification for detected anomaly
from .notification_engine import on_anomaly
on_anomaly(anomaly, db)

=======
>>>>>>> origin/main
return anomaly
58 changes: 58 additions & 0 deletions backend/app/forensics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""
GridShield Backend — Forensics Report Routes
Ingest and query firmware forensic incident reports.
"""

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session

from . import models, schemas
from .database import get_db
from .notification_engine import on_forensics_report

router = APIRouter(prefix="/api/forensics", tags=["Forensics"])


@router.post("/reports", response_model=schemas.ForensicsReportResponse, status_code=201)
def create_forensics_report(
report: schemas.ForensicsReportCreate,
db: Session = Depends(get_db),
):
"""Ingest a forensic incident report from firmware."""
db_report = models.ForensicsReport(**report.model_dump())
db.add(db_report)
db.commit()
db.refresh(db_report)

# Auto-generate notification for critical/high severity reports
if db_report.severity in ("critical", "high"):
on_forensics_report(db_report, db)

return db_report


@router.get("/reports", response_model=list[schemas.ForensicsReportResponse])
def list_forensics_reports(
meter_id: int | None = None,
report_type: str | None = None,
limit: int = Query(default=50, le=200),
db: Session = Depends(get_db),
):
"""List forensics reports with optional filters."""
query = db.query(models.ForensicsReport)
if meter_id is not None:
query = query.filter(models.ForensicsReport.meter_id == meter_id)
if report_type is not None:
query = query.filter(models.ForensicsReport.report_type == report_type)
return query.order_by(models.ForensicsReport.timestamp.desc()).limit(limit).all()


@router.get("/reports/{report_id}", response_model=schemas.ForensicsReportResponse)
def get_forensics_report(report_id: int, db: Session = Depends(get_db)):
"""Get a single forensics report by ID."""
report = db.query(models.ForensicsReport).filter(
models.ForensicsReport.id == report_id
).first()
if report is None:
raise HTTPException(status_code=404, detail="Report not found")
return report
23 changes: 23 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,18 @@
from .database import Base, engine
<<<<<<< HEAD
from .export import router as export_router
from .forensics import router as forensics_router
from .meters import router as meters_router
from .notifications import router as notifications_router
=======
<<<<<<< HEAD
from .export import router as export_router
=======
>>>>>>> 469b660da70c38354fe5127353f451559b605a7f
from .meters import router as meters_router
>>>>>>> origin/main
from .routes import router
from .webhooks import router as webhooks_router


@asynccontextmanager
Expand All @@ -29,11 +37,15 @@ async def lifespan(app: FastAPI):
description="REST API for GridShield AMI Security System — "
"Meter data ingestion, tamper alerts, anomaly monitoring, "
"and fleet management.",
<<<<<<< HEAD
version="3.3.0",
=======
<<<<<<< HEAD
version="3.2.0",
=======
version="3.1.0",
>>>>>>> 469b660da70c38354fe5127353f451559b605a7f
>>>>>>> origin/main
lifespan=lifespan,
)

Expand All @@ -51,19 +63,30 @@ async def lifespan(app: FastAPI):
app.include_router(meters_router)
<<<<<<< HEAD
app.include_router(export_router)
app.include_router(notifications_router)
app.include_router(webhooks_router)
app.include_router(forensics_router)
=======
<<<<<<< HEAD
app.include_router(export_router)
=======
>>>>>>> 469b660da70c38354fe5127353f451559b605a7f
>>>>>>> origin/main


@app.get("/", tags=["Root"])
def root():
"""Health check."""
return {
"name": "GridShield API",
<<<<<<< HEAD
"version": "3.3.0",
=======
<<<<<<< HEAD
"version": "3.2.0",
=======
"version": "3.1.0",
>>>>>>> 469b660da70c38354fe5127353f451559b605a7f
>>>>>>> origin/main
"status": "running",
}
48 changes: 48 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,51 @@ class Meter(Base):
status = Column(String(20), default="offline") # online / offline / tampered
registered_at = Column(DateTime, default=datetime.utcnow, nullable=False)
last_seen_at = Column(DateTime, default=None, nullable=True)
<<<<<<< HEAD


class Notification(Base):
"""In-app notification generated from alerts and anomalies."""

__tablename__ = "notifications"

id = Column(Integer, primary_key=True, index=True, autoincrement=True)
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False)
notification_type = Column(String(30), nullable=False) # tamper_alert / anomaly / forensics
severity = Column(String(20), default="info") # info / low / medium / high / critical
message = Column(String(500), nullable=False)
meter_id = Column(BigInteger, nullable=True, index=True)
is_read = Column(Boolean, default=False)
source_id = Column(Integer, nullable=True) # FK-like reference to alert/anomaly/report id


class WebhookConfig(Base):
"""External webhook endpoint configuration."""

__tablename__ = "webhook_configs"

id = Column(Integer, primary_key=True, index=True, autoincrement=True)
url = Column(String(500), nullable=False)
secret = Column(String(100), default="")
enabled = Column(Boolean, default=True)
event_types = Column(String(200), default="all") # comma-separated: all, tamper_alert, anomaly, forensics
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
description = Column(String(200), default="")


class ForensicsReport(Base):
"""Forensic incident report from firmware."""

__tablename__ = "forensics_reports"

id = Column(Integer, primary_key=True, index=True, autoincrement=True)
meter_id = Column(BigInteger, nullable=False, index=True)
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False)
report_type = Column(String(30), nullable=False) # physical / network / fraud / hybrid
severity = Column(String(20), default="low")
confidence = Column(Integer, default=0)
event_count = Column(Integer, default=0)
summary = Column(String(1000), default="")
raw_payload = Column(String(5000), default="{}")
=======
>>>>>>> origin/main
173 changes: 173 additions & 0 deletions backend/app/notification_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""
GridShield Backend — Notification Engine

Automatically creates in-app notifications when tamper alerts,
anomalies, or forensics reports are ingested. Also dispatches
configured webhooks for external integrations.
"""

import logging
from datetime import datetime

from sqlalchemy.orm import Session

from . import models

logger = logging.getLogger("gridshield.notifications")


def create_notification(
db: Session,
notification_type: str,
message: str,
severity: str = "info",
meter_id: int | None = None,
source_id: int | None = None,
) -> models.Notification:
"""Create and persist an in-app notification."""
notif = models.Notification(
timestamp=datetime.utcnow(),
notification_type=notification_type,
severity=severity,
message=message,
meter_id=meter_id,
is_read=False,
source_id=source_id,
)
db.add(notif)
db.commit()
db.refresh(notif)
return notif


def dispatch_webhooks(
db: Session,
event_type: str,
payload: dict,
) -> int:
"""
Send HTTP POST to all enabled webhooks matching event_type.

Returns the number of webhooks successfully dispatched.
Failures are logged but do not raise exceptions.
"""
webhooks = db.query(models.WebhookConfig).filter(
models.WebhookConfig.enabled == True # noqa: E712
).all()

dispatched = 0
for wh in webhooks:
# Check if webhook listens to this event type
types = [t.strip() for t in wh.event_types.split(",")]
if "all" not in types and event_type not in types:
continue

try:
import httpx

headers = {"Content-Type": "application/json"}
if wh.secret:
headers["X-Webhook-Secret"] = wh.secret

with httpx.Client(timeout=5.0) as client:
resp = client.post(wh.url, json=payload, headers=headers)
if resp.status_code < 400:
dispatched += 1
else:
logger.warning(
"Webhook %s returned %d", wh.url, resp.status_code
)
except Exception as exc:
logger.warning("Webhook dispatch to %s failed: %s", wh.url, exc)

return dispatched


def on_alert(alert: models.TamperAlert, db: Session) -> models.Notification:
"""Handle a new tamper alert — create notification and dispatch webhooks."""
severity_map = {0: "info", 1: "low", 2: "medium", 3: "high", 4: "critical"}
sev = severity_map.get(alert.severity, "info")

meter_hex = format(alert.meter_id, "X")[-8:] if alert.meter_id else "?"
message = f"Tamper alert: {alert.tamper_type} on meter {meter_hex} (severity: {sev})"

notif = create_notification(
db,
notification_type="tamper_alert",
message=message,
severity=sev,
meter_id=alert.meter_id,
source_id=alert.id,
)

dispatch_webhooks(db, "tamper_alert", {
"event": "tamper_alert",
"alert_id": alert.id,
"meter_id": alert.meter_id,
"tamper_type": alert.tamper_type,
"severity": alert.severity,
"timestamp": alert.timestamp.isoformat() if alert.timestamp else None,
})

return notif


def on_anomaly(anomaly: models.AnomalyLog, db: Session) -> models.Notification:
"""Handle a new anomaly — create notification and dispatch webhooks."""
meter_hex = format(anomaly.meter_id, "X")[-8:] if anomaly.meter_id else "?"
message = (
f"Anomaly detected: {anomaly.anomaly_type} on meter {meter_hex} "
f"(deviation: {anomaly.deviation_percent:.1f}%, severity: {anomaly.severity})"
)

notif = create_notification(
db,
notification_type="anomaly",
message=message,
severity=anomaly.severity,
meter_id=anomaly.meter_id,
source_id=anomaly.id,
)

dispatch_webhooks(db, "anomaly", {
"event": "anomaly",
"anomaly_id": anomaly.id,
"meter_id": anomaly.meter_id,
"anomaly_type": anomaly.anomaly_type,
"severity": anomaly.severity,
"deviation_percent": anomaly.deviation_percent,
"timestamp": anomaly.timestamp.isoformat() if anomaly.timestamp else None,
})

return notif


def on_forensics_report(report: models.ForensicsReport, db: Session) -> models.Notification:
"""Handle a new forensics report — create notification and dispatch webhooks."""
meter_hex = format(report.meter_id, "X")[-8:] if report.meter_id else "?"
message = (
f"Forensics report: {report.report_type} from meter {meter_hex} "
f"({report.event_count} events, confidence: {report.confidence}%)"
)

notif = create_notification(
db,
notification_type="forensics",
message=message,
severity=report.severity,
meter_id=report.meter_id,
source_id=report.id,
)

dispatch_webhooks(db, "forensics", {
"event": "forensics_report",
"report_id": report.id,
"meter_id": report.meter_id,
"report_type": report.report_type,
"severity": report.severity,
"confidence": report.confidence,
"event_count": report.event_count,
"timestamp": report.timestamp.isoformat() if report.timestamp else None,
})

return notif
Loading
Loading