feat: phase 1C JWT UI, events/hosts API, Vue frontend

This commit is contained in:
PTah
2026-05-26 21:46:34 +10:00
parent d8a8329397
commit bdfc7016e6
27 changed files with 2498 additions and 23 deletions
+31
View File
@@ -0,0 +1,31 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.auth.jwt_auth import create_access_token
from app.config import get_settings
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest) -> TokenResponse:
settings = get_settings()
if not settings.sac_admin_password:
raise HTTPException(
status_code=503,
detail="SAC_ADMIN_PASSWORD is not configured",
)
if body.username != settings.sac_admin_username or body.password != settings.sac_admin_password:
raise HTTPException(status_code=401, detail="Invalid username or password")
token = create_access_token(body.username)
return TokenResponse(access_token=token)
+113 -2
View File
@@ -1,12 +1,17 @@
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload
from app.auth.api_key import get_api_key_auth
from app.auth.jwt_auth import get_current_user
from app.config import get_settings
from app.database import get_db
from app.models import Event, Host
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
from app.services.ingest import ingest_event
from app.services.schema_validate import validate_event_payload
@@ -41,3 +46,109 @@ def post_event(
created=created,
sac_event_url=f"{base}/api/v1/events/{event.id}",
)
def _parse_optional_dt(value: str | None) -> datetime | None:
if not value:
return None
return datetime.fromisoformat(value.replace("Z", "+00:00"))
@router.get("", response_model=EventListResponse)
def list_events(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
severity: str | None = None,
type: str | None = None,
host_id: int | None = None,
hostname: str | None = None,
from_time: str | None = Query(None, alias="from"),
to_time: str | None = Query(None, alias="to"),
q: str | None = None,
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> EventListResponse:
stmt = select(Event).join(Host).options(joinedload(Event.host))
count_stmt = select(func.count()).select_from(Event).join(Host)
if severity:
stmt = stmt.where(Event.severity == severity)
count_stmt = count_stmt.where(Event.severity == severity)
if type:
stmt = stmt.where(Event.type == type)
count_stmt = count_stmt.where(Event.type == type)
if host_id is not None:
stmt = stmt.where(Event.host_id == host_id)
count_stmt = count_stmt.where(Event.host_id == host_id)
if hostname:
like = f"%{hostname}%"
stmt = stmt.where(Host.hostname.ilike(like))
count_stmt = count_stmt.where(Host.hostname.ilike(like))
dt_from = _parse_optional_dt(from_time)
dt_to = _parse_optional_dt(to_time)
if dt_from:
stmt = stmt.where(Event.occurred_at >= dt_from)
count_stmt = count_stmt.where(Event.occurred_at >= dt_from)
if dt_to:
stmt = stmt.where(Event.occurred_at <= dt_to)
count_stmt = count_stmt.where(Event.occurred_at <= dt_to)
if q:
like = f"%{q}%"
stmt = stmt.where(Event.summary.ilike(like) | Event.title.ilike(like))
count_stmt = count_stmt.where(Event.summary.ilike(like) | Event.title.ilike(like))
total = db.scalar(count_stmt) or 0
rows = db.scalars(
stmt.order_by(Event.occurred_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
).all()
items = [
EventSummary(
id=e.id,
event_id=e.event_id,
host_id=e.host_id,
hostname=e.host.hostname,
occurred_at=e.occurred_at,
received_at=e.received_at,
category=e.category,
type=e.type,
severity=e.severity,
title=e.title,
summary=e.summary,
)
for e in rows
]
return EventListResponse(items=items, total=total, page=page, page_size=page_size)
@router.get("/{event_db_id}", response_model=EventDetail)
def get_event(
event_db_id: int,
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> EventDetail:
event = db.scalar(
select(Event).where(Event.id == event_db_id).options(joinedload(Event.host))
)
if event is None:
raise HTTPException(status_code=404, detail="Event not found")
return EventDetail(
id=event.id,
event_id=event.event_id,
host_id=event.host_id,
hostname=event.host.hostname,
occurred_at=event.occurred_at,
received_at=event.received_at,
category=event.category,
type=event.type,
severity=event.severity,
title=event.title,
summary=event.summary,
details=event.details,
raw=event.raw,
dedup_key=event.dedup_key,
correlation_id=event.correlation_id,
payload=event.payload,
)
+59
View File
@@ -0,0 +1,59 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth.jwt_auth import get_current_user
from app.database import get_db
from app.models import Event, Host
from app.schemas.list_models import HostListResponse, HostSummary
router = APIRouter(prefix="/hosts", tags=["hosts"])
@router.get("", response_model=HostListResponse)
def list_hosts(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
product: str | None = None,
hostname: str | None = None,
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> HostListResponse:
stmt = select(Host)
count_stmt = select(func.count()).select_from(Host)
if product:
stmt = stmt.where(Host.product == product)
count_stmt = count_stmt.where(Host.product == product)
if hostname:
like = f"%{hostname}%"
stmt = stmt.where(Host.hostname.ilike(like))
count_stmt = count_stmt.where(Host.hostname.ilike(like))
total = db.scalar(count_stmt) or 0
rows = db.scalars(
stmt.order_by(Host.last_seen_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
).all()
items: list[HostSummary] = []
for host in rows:
event_count = db.scalar(
select(func.count()).select_from(Event).where(Event.host_id == host.id)
)
items.append(
HostSummary(
id=host.id,
hostname=host.hostname,
display_name=host.display_name,
os_family=host.os_family,
product=host.product,
product_version=host.product_version,
ipv4=host.ipv4,
last_seen_at=host.last_seen_at,
event_count=int(event_count or 0),
)
)
return HostListResponse(items=items, total=total, page=page, page_size=page_size)
+3 -1
View File
@@ -1,7 +1,9 @@
from fastapi import APIRouter
from app.api.v1 import events, health
from app.api.v1 import auth, events, health, hosts
api_router = APIRouter()
api_router.include_router(health.router)
api_router.include_router(auth.router)
api_router.include_router(events.router)
api_router.include_router(hosts.router)
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from app.config import get_settings
bearer_scheme = HTTPBearer(auto_error=False)
def create_access_token(subject: str) -> str:
settings = get_settings()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": subject, "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> str:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
settings = get_settings()
try:
payload = jwt.decode(
credentials.credentials,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
username = payload.get("sub")
if not username:
raise HTTPException(status_code=401, detail="Invalid token")
return str(username)
except JWTError as exc:
raise HTTPException(status_code=401, detail="Invalid token") from exc
+9
View File
@@ -1,7 +1,9 @@
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select
from app.api.v1.router import api_router
@@ -10,6 +12,8 @@ from app.config import get_settings
from app.database import SessionLocal
from app.models import ApiKey
ROOT = Path(__file__).resolve().parents[2]
def bootstrap_api_key() -> None:
settings = get_settings()
@@ -60,6 +64,11 @@ def create_app() -> FastAPI:
app.include_router(api_router, prefix="/api/v1")
app.include_router(health_router)
frontend_dist = ROOT / "frontend" / "dist"
if frontend_dist.is_dir():
app.mount("/", StaticFiles(directory=str(frontend_dist), html=True), name="ui")
return app
+55
View File
@@ -0,0 +1,55 @@
from datetime import datetime
from pydantic import BaseModel, Field
class HostSummary(BaseModel):
id: int
hostname: str
display_name: str | None
os_family: str
product: str
product_version: str | None
ipv4: str | None
last_seen_at: datetime
event_count: int | None = None
model_config = {"from_attributes": True}
class HostListResponse(BaseModel):
items: list[HostSummary]
total: int
page: int
page_size: int
class EventSummary(BaseModel):
id: int
event_id: str
host_id: int
hostname: str
occurred_at: datetime
received_at: datetime
category: str
type: str
severity: str
title: str
summary: str
model_config = {"from_attributes": True}
class EventDetail(EventSummary):
details: dict | None = None
raw: dict | None = None
dedup_key: str | None = None
correlation_id: str | None = None
payload: dict
class EventListResponse(BaseModel):
items: list[EventSummary]
total: int
page: int
page_size: int