Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ dmypy.json
# Playwright
tests/e2e/playwright-report/
tests/e2e/test-results/
tests/e2e/.auth/

# Misc local files
logs.txt
Expand Down
63 changes: 63 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# OneAlert Mission Control Design System

## Physical scene

An analyst uses OneAlert on a 24–27 inch monitor during a long shift in a dim operations room, repeatedly scanning changing severity, device, and approval signals. A low-glare dark theme with restrained high-contrast accents is therefore the default.

## Color strategy

Restrained tinted neutrals with semantic data colors. Cyan is used for navigation, focus, and primary actions—not decoration.

- Canvas: ink/navy 950–900.
- Elevated surfaces: navy 875–800.
- Borders: cool slate 750–650.
- Primary: cyan 500–300.
- Danger: rose/red 500–300.
- Warning: amber 500–300.
- Success: emerald 500–300.
- Text: tinted neutral 50–500; pure white and pure black are avoided.

The Tailwind theme in `frontend-v2/src/index.css` is the source of truth. Components consume semantic tokens rather than ad-hoc colors.

## Typography

- UI: Inter/system sans, compact and highly legible.
- Technical data: ui-monospace for IPs, CVEs, ports, IDs, timestamps, and generated queries.
- Page titles: 24–30px, 700 weight.
- Section titles: 16–18px, 600–700 weight.
- Body: 13–14px.
- Labels/eyebrows: 10–12px with restrained tracking.

## Geometry and elevation

- 4px base spacing scale: 4, 8, 12, 16, 24, 32, 48.
- Controls: 36–40px; mobile touch targets at least 44px.
- Radii: 4px for tokens, 8px for controls/panels, 12px only for major overlays.
- Panels use borders and tonal separation; shadows are reserved for overlays and sticky command surfaces.

## Application shell

- Desktop: fixed 248px grouped sidebar and sticky 56px command bar.
- Tablet: collapsible/icon-aware navigation.
- Mobile: compact command bar plus modal navigation drawer; dense tables scroll horizontally.
- Main content uses a fluid grid and wide working area, not a narrow centered container.

## Standard page anatomy

1. Page header: eyebrow/breadcrumb, title, context, primary action.
2. Operational strip: compact key counts, scope, freshness, and health.
3. Filter/action bar: search, filters, result count, reset/retry.
4. Primary work surface: dense table, timeline, graph, or investigation view.
5. Contextual detail: drawer or secondary panel without losing list position.

## Required states

Every remote-data surface defines loading skeleton, populated, filtered-empty, true-empty, partial failure, full failure with retry, stale/refreshing, and permission/session failure. Errors use icon + title + actionable explanation and never collapse to zero-valued metrics.

## Interaction and accessibility

- Semantic buttons, links, tables, headings, labels, and status regions.
- Visible focus; keyboard-accessible navigation/drawers/dialogs; Escape closes overlays.
- Status never relies on color alone.
- Motion is functional and under 250ms; `prefers-reduced-motion` disables it.
- Charts have textual summaries or labels.
40 changes: 40 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# OneAlert Product Context

## Register

Product UI — an operational security application where design serves fast, accurate decisions.

## Product purpose

OneAlert is an open-source AI-assisted security operations platform for industrial and OT/ICS networks. It gives small and mid-sized operators the asset visibility, alert triage, investigation, response, hunting, validation, and audit workflows normally found in expensive enterprise platforms.

## Primary users

- SOC analysts who continuously scan dense alert, event, and investigation data.
- OT engineers who need device, Purdue-level, protocol, and topology context without disrupting operations.
- Security managers who prioritize exposure, approvals, and response readiness.

## Product principles

1. Preserve operational density; improve hierarchy instead of hiding information.
2. Every summary must lead to a filtered, actionable detail view.
3. OT safety and human approval gates must remain visible near risky actions.
4. Partial, stale, failed, and empty data must never look like a healthy zero state.
5. Technical identifiers remain easy to scan and copy.
6. Accessibility and keyboard operation are production requirements.

## Brand and tone

OneAlert should feel calm under pressure: precise, credible, industrial, and quietly advanced. Language is direct and operational. The interface avoids consumer-app playfulness, neon cyberpunk decoration, excessive gradients, and large low-information cards.

## Approved visual direction

Dense “Mission Control”: tinted near-black/navy surfaces, cool cyan as the primary interactive signal, amber for attention, red only for genuine danger, and green only for verified healthy states. The interface uses compact typography, strong alignment, restrained borders, and purposeful depth.

## Competitive references

- Claroty: asset categorization and risk-first inventory summaries.
- Nozomi Vantage: global-to-site health and drill-down.
- Dragos Platform: prioritized insights that unify alerts, vulnerabilities, assets, and guidance.
- Tenable OT: dense role-specific dashboards, interactive widgets, and graph/table drill-down.
- Mosaic/enterprise admin patterns: consistent React/Tailwind navigation, tables, filters, and responsive structure.
2 changes: 1 addition & 1 deletion backend/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

uvicorn.run(
"backend.main:app",
host="0.0.0.0",
host="127.0.0.1",
port=8000,
reload=False,
log_level="info",
Expand Down
15 changes: 6 additions & 9 deletions backend/models/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@
request/response validation and serialization.
"""

from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from backend.database.db import Base
from pydantic import BaseModel
from typing import Optional
from pydantic import BaseModel, ConfigDict
from typing import Literal, Optional
from datetime import datetime


Expand All @@ -41,8 +41,8 @@ class OrgCreate(BaseModel):
"""Schema for organization creation."""
name: str
slug: str
plan: str = "free"
model_config = {"from_attributes": True}
plan: Literal["free"] = "free"
model_config = ConfigDict(from_attributes=True, extra="forbid")


class OrgResponse(BaseModel):
Expand All @@ -60,7 +60,4 @@ class OrgResponse(BaseModel):
class OrgUpdate(BaseModel):
"""Schema for organization updates."""
name: Optional[str] = None
plan: Optional[str] = None
max_assets: Optional[int] = None
max_users: Optional[int] = None
model_config = {"from_attributes": True}
model_config = ConfigDict(from_attributes=True, extra="forbid")
24 changes: 18 additions & 6 deletions backend/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from backend.database.db import Base
from pydantic import BaseModel, Field, EmailStr # Ensure compatibility with Pydantic v2
from pydantic import BaseModel, EmailStr, field_validator
from typing import Optional, List
from datetime import datetime
from fastapi import Depends, HTTPException, status
from fastapi import Depends
from backend.database.db import get_async_db
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from backend.services.auth_service import validate_new_password


class User(Base):
Expand Down Expand Up @@ -78,6 +79,11 @@ class UserBase(BaseModel):
class UserCreate(UserBase):
"""Schema for user creation."""
password: str

@field_validator("password")
@classmethod
def validate_password(cls, value: str) -> str:
return validate_new_password(value)

model_config = {"from_attributes": True}

Expand Down Expand Up @@ -105,20 +111,26 @@ class UserUpdate(BaseModel):
webhook_url: Optional[str] = None
mfa_enabled: Optional[bool] = False
mfa_secret: Optional[str] = None

@field_validator("password")
@classmethod
def validate_password(cls, value: Optional[str]) -> Optional[str]:
return validate_new_password(value) if value is not None else value

model_config = {"from_attributes": True}


class UserResponse(UserBase):
class UserResponse(BaseModel):
"""Schema for user response."""
id: int
email: EmailStr
full_name: Optional[str] = None
company: Optional[str] = None
is_active: bool
is_verified: bool
created_at: datetime
updated_at: Optional[datetime] = None
org_id: Optional[int] = None
slack_webhook_url: Optional[str] = None
webhook_url: Optional[str] = None
mfa_enabled: Optional[bool] = False

model_config = {"from_attributes": True}
Expand All @@ -141,4 +153,4 @@ class TokenData(BaseModel):

async def list_users(db: AsyncSession = Depends(get_async_db)) -> List[User]:
result = await db.execute(select(User))
return result.scalars().all()
return result.scalars().all()
60 changes: 51 additions & 9 deletions backend/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
from datetime import timedelta
import secrets
import time
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional, List

from backend.database.db import get_async_db
from pydantic import BaseModel
from backend.models.user import User, UserCreate, UserResponse, Token, GitHubUserCreate
from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator
from backend.models.user import User, UserCreate, UserResponse, Token
from backend.models.audit_log import AuditLog
from backend.services.auth_service import (
create_access_token,
Expand Down Expand Up @@ -76,6 +76,12 @@ async def get_current_user(

if user is None:
raise credentials_exception

if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user",
)

return user

Expand Down Expand Up @@ -127,6 +133,7 @@ async def register_user(
@limiter.limit("5/minute")
async def login_user(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_async_db)
):
Expand Down Expand Up @@ -171,9 +178,32 @@ async def login_user(
data={"sub": user.email}, expires_delta=access_token_expires
)

response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
secure=settings.environment == "production",
samesite="lax",
max_age=settings.access_token_expire_minutes * 60,
path="/",
)

return {"access_token": access_token, "token_type": "bearer"}


@router.post("/logout")
async def logout_user(response: Response):
"""Expire the browser session cookie; safe to call even when logged out."""
response.delete_cookie(
key="access_token",
path="/",
secure=settings.environment == "production",
httponly=True,
samesite="lax",
)
return {"success": True}


@router.get("/me", response_model=UserResponse)
async def read_current_user(current_user: User = Depends(get_active_user)):
"""Get current user profile."""
Expand Down Expand Up @@ -246,18 +276,30 @@ async def github_callback(code: str, state: Optional[str] = None):
)


class WebhookIntegrationsUpdate(BaseModel):
"""Write-only notification endpoints; credentials are never serialized back."""

slack_webhook_url: Optional[HttpUrl] = None
webhook_url: Optional[HttpUrl] = None
model_config = ConfigDict(extra="forbid")

@field_validator("slack_webhook_url", "webhook_url")
@classmethod
def require_https(cls, value: Optional[HttpUrl]) -> Optional[HttpUrl]:
if value is not None and value.scheme != "https":
raise ValueError("Webhook URLs must use HTTPS")
return value


@router.patch("/me/integrations", response_model=UserResponse)
async def update_my_integrations(
slack_webhook_url: Optional[str] = None,
webhook_url: Optional[str] = None,
payload: WebhookIntegrationsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_async_db)
):
"""Update Slack/Webhook integration URLs for the current user."""
if slack_webhook_url is not None:
current_user.slack_webhook_url = slack_webhook_url
if webhook_url is not None:
current_user.webhook_url = webhook_url
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(current_user, field, str(value) if value is not None else None)
db.add(current_user)
await db.commit()
await db.refresh(current_user)
Expand Down
Loading
Loading