From 174919da4e5af546cfec4e4d7f1d460478a6eb61 Mon Sep 17 00:00:00 2001 From: Kitos Date: Fri, 6 Feb 2026 16:30:35 +0100 Subject: [PATCH] feat(phase-9): implement MVP polishing and closure T-032: User management admin panel - backend users router with CRUD, frontend UsersPage with modals T-033: Audit log viewer - backend audit router with filters/pagination, frontend AuditLogPage T-034: Global error handling - ErrorBoundary, LoadingSpinner, ErrorMessage, Toast components T-035: Backend tests - pytest setup with SQLite, tests for health/auth/techniques/tests T-036: Documentation - Updated README with testing section, created docs/API.md --- README.md | 60 ++- backend/app/main.py | 51 ++- backend/app/routers/audit.py | 118 ++++++ backend/app/routers/users.py | 153 +++++++ backend/app/schemas/audit.py | 31 ++ backend/app/schemas/user.py | 45 ++ backend/pytest.ini | 5 + backend/requirements.txt | 5 + backend/tests/__init__.py | 1 + backend/tests/conftest.py | 118 ++++++ backend/tests/test_auth.py | 81 ++++ backend/tests/test_health.py | 8 + backend/tests/test_techniques.py | 128 ++++++ backend/tests/test_tests.py | 165 +++++++ docs/API.md | 385 +++++++++++++++++ frontend/src/App.tsx | 18 + frontend/src/api/audit.ts | 58 +++ frontend/src/api/client.ts | 29 +- frontend/src/api/users.ts | 43 ++ frontend/src/components/ErrorBoundary.tsx | 101 +++++ frontend/src/components/ErrorMessage.tsx | 40 ++ frontend/src/components/LoadingSpinner.tsx | 36 ++ frontend/src/components/Sidebar.tsx | 4 + frontend/src/components/Toast.tsx | 88 ++++ frontend/src/main.tsx | 20 +- frontend/src/pages/AuditLogPage.tsx | 293 +++++++++++++ frontend/src/pages/UsersPage.tsx | 472 +++++++++++++++++++++ 27 files changed, 2539 insertions(+), 17 deletions(-) create mode 100644 backend/app/routers/audit.py create mode 100644 backend/app/routers/users.py create mode 100644 backend/app/schemas/audit.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/pytest.ini create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_health.py create mode 100644 backend/tests/test_techniques.py create mode 100644 backend/tests/test_tests.py create mode 100644 docs/API.md create mode 100644 frontend/src/api/audit.ts create mode 100644 frontend/src/api/users.ts create mode 100644 frontend/src/components/ErrorBoundary.tsx create mode 100644 frontend/src/components/ErrorMessage.tsx create mode 100644 frontend/src/components/LoadingSpinner.tsx create mode 100644 frontend/src/components/Toast.tsx create mode 100644 frontend/src/pages/AuditLogPage.tsx create mode 100644 frontend/src/pages/UsersPage.tsx diff --git a/README.md b/README.md index 0e87481..fae0dd8 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,21 @@ Once the backend is running, access the interactive API documentation at: | GET | `/api/v1/metrics/summary` | Authenticated | Global coverage summary (counts + percentage) | | GET | `/api/v1/metrics/by-tactic` | Authenticated | Coverage breakdown per MITRE tactic | +### Users (Admin) +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/api/v1/users` | Admin | List all users | +| POST | `/api/v1/users` | Admin | Create new user | +| GET | `/api/v1/users/{id}` | Admin | Get user by ID | +| PATCH | `/api/v1/users/{id}` | Admin | Update user (role, email, active status) | + +### Audit Logs (Admin) +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/api/v1/audit-logs` | Admin | List audit logs (filters: `?action=`, `?entity_type=`, `?start_date=`, `?end_date=`) | +| GET | `/api/v1/audit-logs/actions` | Admin | List distinct action types | +| GET | `/api/v1/audit-logs/entity-types` | Admin | List distinct entity types | + ## Project Structure ``` @@ -187,7 +202,9 @@ Aegis/ │ │ ├── tests.py # CRUD tests (create, detail, update, validate, reject) │ │ ├── evidence.py # Upload evidence, presigned download │ │ ├── system.py # MITRE sync trigger, scheduler status -│ │ └── metrics.py # Coverage summary & per-tactic breakdown +│ │ ├── metrics.py # Coverage summary & per-tactic breakdown +│ │ ├── users.py # User management (admin only) +│ │ └── audit.py # Audit log viewer (admin only) │ ├── dependencies/ # FastAPI dependencies (DI) │ │ └── auth.py # get_current_user, require_role, require_any_role │ ├── jobs/ # Background scheduled jobs @@ -213,7 +230,9 @@ Aegis/ │ ├── techniques.ts # getTechniques(), getTechniqueByMitreId() │ ├── tests.ts # createTest(), validateTest(), rejectTest() │ ├── evidence.ts # uploadEvidence(), getEvidence() - │ └── system.ts # triggerMitreSync(), triggerIntelScan() + │ ├── system.ts # triggerMitreSync(), triggerIntelScan() + │ ├── users.ts # getUsers(), createUser(), updateUser() + │ └── audit.ts # getAuditLogs(), getAuditActions() ├── context/ │ └── AuthContext.tsx # Auth state: user, login, logout, isLoading ├── components/ @@ -226,7 +245,11 @@ Aegis/ │ ├── TechniqueCell.tsx # Individual technique cell in matrix │ ├── TestForm.tsx # Reusable test creation/edit form │ ├── EvidenceUpload.tsx # Drag & drop file upload - │ └── EvidenceList.tsx # Evidence file listing + │ ├── EvidenceList.tsx # Evidence file listing + │ ├── ErrorBoundary.tsx # Global error boundary + │ ├── ErrorMessage.tsx # Reusable error display + │ ├── LoadingSpinner.tsx # Reusable loading indicator + │ └── Toast.tsx # Toast notification system ├── pages/ │ ├── LoginPage.tsx # User authentication form │ ├── DashboardPage.tsx # Coverage metrics dashboard with summary cards @@ -235,7 +258,9 @@ Aegis/ │ ├── TestsPage.tsx # Tests overview and navigation │ ├── TestCreatePage.tsx # Test creation form │ ├── TestDetailPage.tsx # Test details with evidence upload - │ └── SystemPage.tsx # Admin panel for MITRE sync & intel scan + │ ├── SystemPage.tsx # Admin panel for MITRE sync & intel scan + │ ├── UsersPage.tsx # User management (admin only) + │ └── AuditLogPage.tsx # Audit log viewer (admin only) ├── types/ │ └── models.ts # TS interfaces matching backend schemas ├── hooks/ @@ -293,6 +318,33 @@ docker exec -w /app aegis-backend-1 alembic current - **MinIO Console**: http://localhost:9001 (login: `minioadmin` / `minioadmin`) - **PostgreSQL**: `psql -h localhost -p 5433 -U postgres -d attackdb` +### Running Tests + +The backend includes a test suite using pytest: + +```bash +# Install test dependencies (if running locally) +pip install pytest pytest-asyncio httpx + +# Run all tests +docker exec -w /app aegis-backend-1 pytest + +# Run tests with verbose output +docker exec -w /app aegis-backend-1 pytest -v + +# Run specific test file +docker exec -w /app aegis-backend-1 pytest tests/test_auth.py + +# Run locally (requires SQLite) +cd backend && pytest +``` + +Test files: +- `test_health.py` - Health endpoint tests +- `test_auth.py` - Authentication and authorization tests +- `test_techniques.py` - Technique CRUD tests +- `test_tests.py` - Security test CRUD and validation tests + ## User Roles | Role | Description | diff --git a/backend/app/main.py b/backend/app/main.py index 00f679f..08e510e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,8 +1,11 @@ import logging from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request, status from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from sqlalchemy.exc import SQLAlchemyError from app.routers import auth as auth_router from app.routers import techniques as techniques_router @@ -10,6 +13,8 @@ from app.routers import tests as tests_router from app.routers import evidence as evidence_router from app.routers import system as system_router from app.routers import metrics as metrics_router +from app.routers import users as users_router +from app.routers import audit as audit_router from app.storage import ensure_bucket_exists from app.jobs.mitre_sync_job import start_scheduler, scheduler @@ -47,8 +52,52 @@ app.include_router(tests_router.router, prefix="/api/v1") app.include_router(evidence_router.router, prefix="/api/v1") app.include_router(system_router.router, prefix="/api/v1") app.include_router(metrics_router.router, prefix="/api/v1") +app.include_router(users_router.router, prefix="/api/v1") +app.include_router(audit_router.router, prefix="/api/v1") @app.get("/health") def health(): return {"status": "ok"} + + +# ── Exception Handlers ──────────────────────────────────────────────────── + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + """Handle validation errors with consistent format.""" + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "detail": "Validation error", + "code": "VALIDATION_ERROR", + "errors": exc.errors(), + }, + ) + + +@app.exception_handler(SQLAlchemyError) +async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError): + """Handle database errors.""" + logging.error(f"Database error: {exc}") + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "detail": "Database error occurred", + "code": "DATABASE_ERROR", + }, + ) + + +@app.exception_handler(Exception) +async def general_exception_handler(request: Request, exc: Exception): + """Handle all unhandled exceptions.""" + logging.error(f"Unhandled exception: {exc}") + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "detail": "An internal server error occurred", + "code": "INTERNAL_ERROR", + }, + ) diff --git a/backend/app/routers/audit.py b/backend/app/routers/audit.py new file mode 100644 index 0000000..033a86f --- /dev/null +++ b/backend/app/routers/audit.py @@ -0,0 +1,118 @@ +"""Audit log viewer router (admin only).""" + +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func +from sqlalchemy.orm import Session, joinedload + +from app.database import get_db +from app.dependencies.auth import require_role +from app.models.audit import AuditLog +from app.models.user import User +from app.schemas.audit import AuditLogOut, AuditLogPage + +router = APIRouter(prefix="/audit-logs", tags=["audit"]) + + +@router.get("", response_model=AuditLogPage) +def list_audit_logs( + user_id: Optional[str] = Query(None, description="Filter by user ID"), + action: Optional[str] = Query(None, description="Filter by action type"), + entity_type: Optional[str] = Query(None, description="Filter by entity type"), + start_date: Optional[datetime] = Query(None, description="Filter by start date"), + end_date: Optional[datetime] = Query(None, description="Filter by end date"), + offset: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(50, ge=1, le=100, description="Max records to return"), + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Return paginated audit logs with optional filters. + + **Requires admin role.** + """ + query = db.query(AuditLog).options(joinedload(AuditLog.user)) + + # Apply filters + if user_id: + query = query.filter(AuditLog.user_id == user_id) + if action: + query = query.filter(AuditLog.action == action) + if entity_type: + query = query.filter(AuditLog.entity_type == entity_type) + if start_date: + query = query.filter(AuditLog.timestamp >= start_date) + if end_date: + query = query.filter(AuditLog.timestamp <= end_date) + + # Get total count + total = query.count() + + # Get paginated results + logs = ( + query + .order_by(AuditLog.timestamp.desc()) + .offset(offset) + .limit(limit) + .all() + ) + + # Convert to response format with username + items = [] + for log in logs: + item = AuditLogOut( + id=log.id, + user_id=log.user_id, + username=log.user.username if log.user else None, + action=log.action, + entity_type=log.entity_type, + entity_id=log.entity_id, + timestamp=log.timestamp, + details=log.details, + ) + items.append(item) + + return AuditLogPage( + items=items, + total=total, + offset=offset, + limit=limit, + ) + + +@router.get("/actions", response_model=list[str]) +def list_actions( + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Return a list of distinct action types in the audit log. + + **Requires admin role.** + """ + actions = ( + db.query(AuditLog.action) + .distinct() + .order_by(AuditLog.action) + .all() + ) + return [a[0] for a in actions] + + +@router.get("/entity-types", response_model=list[str]) +def list_entity_types( + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Return a list of distinct entity types in the audit log. + + **Requires admin role.** + """ + types = ( + db.query(AuditLog.entity_type) + .filter(AuditLog.entity_type.isnot(None)) + .distinct() + .order_by(AuditLog.entity_type) + .all() + ) + return [t[0] for t in types] diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..28dd279 --- /dev/null +++ b/backend/app/routers/users.py @@ -0,0 +1,153 @@ +"""User management router (admin only).""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.database import get_db +from app.dependencies.auth import require_role +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate, UserOut +from app.auth import hash_password +from app.services.audit_service import log_action + +router = APIRouter(prefix="/users", tags=["users"]) + +VALID_ROLES = {"admin", "red_tech", "blue_tech", "red_lead", "blue_lead", "viewer"} + + +# --------------------------------------------------------------------------- +# GET /users — list all users +# --------------------------------------------------------------------------- + + +@router.get("", response_model=list[UserOut]) +def list_users( + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Return a list of all users. **Requires admin role.**""" + return db.query(User).order_by(User.username).all() + + +# --------------------------------------------------------------------------- +# POST /users — create a new user +# --------------------------------------------------------------------------- + + +@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED) +def create_user( + payload: UserCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Create a new user. **Requires admin role.**""" + + # Check if username already exists + existing = db.query(User).filter(User.username == payload.username).first() + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Username '{payload.username}' already exists", + ) + + # Validate role + if payload.role not in VALID_ROLES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid role '{payload.role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}", + ) + + user = User( + username=payload.username, + email=payload.email, + hashed_password=hash_password(payload.password), + role=payload.role, + ) + db.add(user) + db.commit() + db.refresh(user) + + log_action( + db, + user_id=current_user.id, + action="create_user", + entity_type="user", + entity_id=user.id, + details={"username": user.username, "role": user.role}, + ) + + return user + + +# --------------------------------------------------------------------------- +# GET /users/{id} — get a single user +# --------------------------------------------------------------------------- + + +@router.get("/{user_id}", response_model=UserOut) +def get_user( + user_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Return a single user by ID. **Requires admin role.**""" + user = db.query(User).filter(User.id == user_id).first() + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + return user + + +# --------------------------------------------------------------------------- +# PATCH /users/{id} — update a user +# --------------------------------------------------------------------------- + + +@router.patch("/{user_id}", response_model=UserOut) +def update_user( + user_id: uuid.UUID, + payload: UserUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Update one or more fields of an existing user. **Requires admin role.**""" + user = db.query(User).filter(User.id == user_id).first() + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + update_data = payload.model_dump(exclude_unset=True) + + # Validate role if being updated + if "role" in update_data and update_data["role"] not in VALID_ROLES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid role '{update_data['role']}'. Must be one of: {', '.join(sorted(VALID_ROLES))}", + ) + + # Hash password if being updated + if "password" in update_data: + update_data["hashed_password"] = hash_password(update_data.pop("password")) + + for field, value in update_data.items(): + setattr(user, field, value) + + db.commit() + db.refresh(user) + + log_action( + db, + user_id=current_user.id, + action="update_user", + entity_type="user", + entity_id=user.id, + details={"updated_fields": list(payload.model_dump(exclude_unset=True).keys())}, + ) + + return user diff --git a/backend/app/schemas/audit.py b/backend/app/schemas/audit.py new file mode 100644 index 0000000..e46bac0 --- /dev/null +++ b/backend/app/schemas/audit.py @@ -0,0 +1,31 @@ +"""Pydantic schemas for Audit Log endpoints.""" + +import uuid +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class AuditLogOut(BaseModel): + """Complete representation of an audit log entry.""" + + id: uuid.UUID + user_id: uuid.UUID | None = None + username: str | None = None # Populated from user relationship + action: str + entity_type: str | None = None + entity_id: str | None = None + timestamp: datetime + details: dict[str, Any] | None = None + + model_config = ConfigDict(from_attributes=True) + + +class AuditLogPage(BaseModel): + """Paginated response for audit logs.""" + + items: list[AuditLogOut] + total: int + offset: int + limit: int diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..5b0b4cb --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,45 @@ +"""Pydantic schemas for User management endpoints.""" + +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, EmailStr + + +# ── Create ────────────────────────────────────────────────────────── + +class UserCreate(BaseModel): + """Payload for creating a new user.""" + + username: str + email: str | None = None + password: str + role: str = "viewer" + + +# ── Update ────────────────────────────────────────────────────────── + +class UserUpdate(BaseModel): + """Payload for partially updating an existing user. + Every field is optional so callers send only what changed.""" + + email: str | None = None + role: str | None = None + is_active: bool | None = None + password: str | None = None + + +# ── Read (full) ───────────────────────────────────────────────────── + +class UserOut(BaseModel): + """Complete representation returned by the API.""" + + id: uuid.UUID + username: str + email: str | None = None + role: str + is_active: bool + created_at: datetime | None = None + last_login: datetime | None = None + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..6d5fa31 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_functions = test_* +addopts = -v --tb=short diff --git a/backend/requirements.txt b/backend/requirements.txt index ecea93a..295facf 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,3 +12,8 @@ requests taxii2-client python-multipart pydantic-settings + +# Testing +pytest +pytest-asyncio +httpx diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..15fe9d8 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,118 @@ +"""Pytest fixtures and configuration for backend tests.""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.main import app +from app.database import Base, get_db +from app.auth import hash_password +from app.models.user import User + +# Use in-memory SQLite for tests +SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def override_get_db(): + """Override the database dependency for testing.""" + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + +@pytest.fixture(scope="function") +def db(): + """Create a fresh database for each test.""" + Base.metadata.create_all(bind=engine) + db = TestingSessionLocal() + yield db + db.close() + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture(scope="function") +def client(db): + """Create a test client with database override.""" + app.dependency_overrides[get_db] = override_get_db + Base.metadata.create_all(bind=engine) + + with TestClient(app) as test_client: + yield test_client + + Base.metadata.drop_all(bind=engine) + app.dependency_overrides.clear() + + +@pytest.fixture(scope="function") +def admin_user(db): + """Create an admin user for testing.""" + user = User( + username="admin", + email="admin@test.com", + hashed_password=hash_password("admin123"), + role="admin", + is_active=True, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@pytest.fixture(scope="function") +def red_tech_user(db): + """Create a red_tech user for testing.""" + user = User( + username="redtech", + email="redtech@test.com", + hashed_password=hash_password("redtech123"), + role="red_tech", + is_active=True, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@pytest.fixture(scope="function") +def admin_token(client, admin_user): + """Get an auth token for the admin user.""" + response = client.post( + "/api/v1/auth/login", + data={"username": "admin", "password": "admin123"}, + ) + return response.json()["access_token"] + + +@pytest.fixture(scope="function") +def red_tech_token(client, red_tech_user): + """Get an auth token for the red_tech user.""" + response = client.post( + "/api/v1/auth/login", + data={"username": "redtech", "password": "redtech123"}, + ) + return response.json()["access_token"] + + +@pytest.fixture(scope="function") +def auth_headers(admin_token): + """Return authorization headers for admin user.""" + return {"Authorization": f"Bearer {admin_token}"} + + +@pytest.fixture(scope="function") +def red_tech_headers(red_tech_token): + """Return authorization headers for red_tech user.""" + return {"Authorization": f"Bearer {red_tech_token}"} diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..709613c --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,81 @@ +"""Tests for authentication endpoints.""" + +import pytest + + +def test_login_success(client, admin_user): + """Test successful login returns a token.""" + response = client.post( + "/api/v1/auth/login", + data={"username": "admin", "password": "admin123"}, + ) + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + +def test_login_wrong_password(client, admin_user): + """Test login with wrong password returns 400.""" + response = client.post( + "/api/v1/auth/login", + data={"username": "admin", "password": "wrongpassword"}, + ) + assert response.status_code == 400 + + +def test_login_nonexistent_user(client): + """Test login with non-existent user returns 400.""" + response = client.post( + "/api/v1/auth/login", + data={"username": "nobody", "password": "password"}, + ) + assert response.status_code == 400 + + +def test_login_inactive_user(client, db): + """Test login with inactive user returns 400.""" + from app.auth import hash_password + from app.models.user import User + + user = User( + username="inactive", + hashed_password=hash_password("password"), + role="viewer", + is_active=False, + ) + db.add(user) + db.commit() + + response = client.post( + "/api/v1/auth/login", + data={"username": "inactive", "password": "password"}, + ) + assert response.status_code == 400 + + +def test_get_me_with_token(client, admin_user, admin_token): + """Test /auth/me returns current user with valid token.""" + response = client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["username"] == "admin" + assert data["role"] == "admin" + + +def test_get_me_without_token(client): + """Test /auth/me returns 401 without token.""" + response = client.get("/api/v1/auth/me") + assert response.status_code == 401 + + +def test_get_me_invalid_token(client): + """Test /auth/me returns 401 with invalid token.""" + response = client.get( + "/api/v1/auth/me", + headers={"Authorization": "Bearer invalidtoken"}, + ) + assert response.status_code == 401 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..2c88390 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,8 @@ +"""Tests for the health endpoint.""" + + +def test_health_endpoint(client): + """Test that the health endpoint returns 200 OK.""" + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/backend/tests/test_techniques.py b/backend/tests/test_techniques.py new file mode 100644 index 0000000..cd2684f --- /dev/null +++ b/backend/tests/test_techniques.py @@ -0,0 +1,128 @@ +"""Tests for technique endpoints.""" + +import pytest + + +def test_list_techniques_requires_auth(client): + """Test that listing techniques requires authentication.""" + response = client.get("/api/v1/techniques") + assert response.status_code == 401 + + +def test_list_techniques_empty(client, auth_headers): + """Test listing techniques when none exist.""" + response = client.get("/api/v1/techniques", headers=auth_headers) + assert response.status_code == 200 + assert response.json() == [] + + +def test_create_technique_requires_admin(client, red_tech_headers): + """Test that creating techniques requires admin role.""" + response = client.post( + "/api/v1/techniques", + json={"mitre_id": "T1001", "name": "Test Technique"}, + headers=red_tech_headers, + ) + assert response.status_code == 403 + + +def test_create_technique_success(client, auth_headers): + """Test successful technique creation.""" + response = client.post( + "/api/v1/techniques", + json={ + "mitre_id": "T1059", + "name": "Command and Scripting Interpreter", + "description": "Test description", + "tactic": "execution", + }, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["mitre_id"] == "T1059" + assert data["name"] == "Command and Scripting Interpreter" + assert data["status_global"] == "not_evaluated" + + +def test_create_duplicate_technique(client, auth_headers): + """Test creating a technique with duplicate mitre_id fails.""" + # Create first technique + client.post( + "/api/v1/techniques", + json={"mitre_id": "T1001", "name": "First"}, + headers=auth_headers, + ) + + # Try to create duplicate + response = client.post( + "/api/v1/techniques", + json={"mitre_id": "T1001", "name": "Second"}, + headers=auth_headers, + ) + assert response.status_code == 409 + + +def test_get_technique_by_mitre_id(client, auth_headers): + """Test getting a single technique by mitre_id.""" + # Create a technique first + client.post( + "/api/v1/techniques", + json={"mitre_id": "T1059", "name": "Test Technique"}, + headers=auth_headers, + ) + + # Get it by mitre_id + response = client.get("/api/v1/techniques/T1059", headers=auth_headers) + assert response.status_code == 200 + assert response.json()["mitre_id"] == "T1059" + + +def test_get_nonexistent_technique(client, auth_headers): + """Test getting a non-existent technique returns 404.""" + response = client.get("/api/v1/techniques/T9999", headers=auth_headers) + assert response.status_code == 404 + + +def test_update_technique(client, auth_headers): + """Test updating a technique.""" + # Create technique + client.post( + "/api/v1/techniques", + json={"mitre_id": "T1059", "name": "Original Name"}, + headers=auth_headers, + ) + + # Update it + response = client.patch( + "/api/v1/techniques/T1059", + json={"name": "Updated Name", "description": "New description"}, + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["name"] == "Updated Name" + + +def test_filter_techniques_by_tactic(client, auth_headers): + """Test filtering techniques by tactic.""" + # Create techniques in different tactics + client.post( + "/api/v1/techniques", + json={"mitre_id": "T1001", "name": "Exec", "tactic": "execution"}, + headers=auth_headers, + ) + client.post( + "/api/v1/techniques", + json={"mitre_id": "T1002", "name": "Persist", "tactic": "persistence"}, + headers=auth_headers, + ) + + # Filter by execution + response = client.get( + "/api/v1/techniques?tactic=execution", + headers=auth_headers, + ) + assert response.status_code == 200 + techniques = response.json() + assert len(techniques) == 1 + assert techniques[0]["mitre_id"] == "T1001" diff --git a/backend/tests/test_tests.py b/backend/tests/test_tests.py new file mode 100644 index 0000000..12214f6 --- /dev/null +++ b/backend/tests/test_tests.py @@ -0,0 +1,165 @@ +"""Tests for security test endpoints.""" + +import pytest + + +@pytest.fixture +def technique(client, auth_headers): + """Create a technique for test association.""" + response = client.post( + "/api/v1/techniques", + json={"mitre_id": "T1059", "name": "Test Technique"}, + headers=auth_headers, + ) + return response.json() + + +def test_create_test_requires_auth(client, technique): + """Test that creating a test requires authentication.""" + response = client.post( + "/api/v1/tests", + json={ + "technique_id": technique["id"], + "name": "Test Name", + }, + ) + assert response.status_code == 401 + + +def test_create_test_success(client, red_tech_headers, technique): + """Test successful test creation.""" + response = client.post( + "/api/v1/tests", + json={ + "technique_id": technique["id"], + "name": "My Security Test", + "description": "Test description", + "platform": "windows", + }, + headers=red_tech_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "My Security Test" + assert data["state"] == "draft" + assert data["technique_id"] == technique["id"] + + +def test_create_test_nonexistent_technique(client, red_tech_headers): + """Test creating a test with non-existent technique fails.""" + response = client.post( + "/api/v1/tests", + json={ + "technique_id": "00000000-0000-0000-0000-000000000000", + "name": "Test", + }, + headers=red_tech_headers, + ) + assert response.status_code == 404 + + +def test_get_test_by_id(client, red_tech_headers, technique): + """Test getting a test by ID.""" + # Create a test + create_response = client.post( + "/api/v1/tests", + json={"technique_id": technique["id"], "name": "Test"}, + headers=red_tech_headers, + ) + test_id = create_response.json()["id"] + + # Get it + response = client.get(f"/api/v1/tests/{test_id}", headers=red_tech_headers) + assert response.status_code == 200 + assert response.json()["id"] == test_id + + +def test_validate_test(client, auth_headers, red_tech_headers, technique): + """Test validating a test updates status correctly.""" + # Create a test + create_response = client.post( + "/api/v1/tests", + json={"technique_id": technique["id"], "name": "Test"}, + headers=red_tech_headers, + ) + test_id = create_response.json()["id"] + + # Validate it (requires lead/admin) + response = client.post( + f"/api/v1/tests/{test_id}/validate", + json={"result": "detected"}, + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["state"] == "validated" + assert data["result"] == "detected" + assert data["validated_by"] is not None + + +def test_validate_test_updates_technique_status(client, auth_headers, red_tech_headers, technique): + """Test that validating a test recalculates technique status.""" + # Create and validate a test + create_response = client.post( + "/api/v1/tests", + json={"technique_id": technique["id"], "name": "Test"}, + headers=red_tech_headers, + ) + test_id = create_response.json()["id"] + + client.post( + f"/api/v1/tests/{test_id}/validate", + json={"result": "detected"}, + headers=auth_headers, + ) + + # Check technique status was updated + response = client.get( + f"/api/v1/techniques/{technique['mitre_id']}", + headers=auth_headers, + ) + assert response.json()["status_global"] == "validated" + + +def test_reject_test(client, auth_headers, red_tech_headers, technique): + """Test rejecting a test.""" + # Create a test + create_response = client.post( + "/api/v1/tests", + json={"technique_id": technique["id"], "name": "Test"}, + headers=red_tech_headers, + ) + test_id = create_response.json()["id"] + + # Reject it + response = client.post( + f"/api/v1/tests/{test_id}/reject", + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["state"] == "rejected" + + +def test_update_test_only_in_draft(client, auth_headers, red_tech_headers, technique): + """Test that tests can only be updated when in draft/rejected state.""" + # Create and validate a test + create_response = client.post( + "/api/v1/tests", + json={"technique_id": technique["id"], "name": "Test"}, + headers=red_tech_headers, + ) + test_id = create_response.json()["id"] + + client.post( + f"/api/v1/tests/{test_id}/validate", + json={"result": "detected"}, + headers=auth_headers, + ) + + # Try to update validated test + response = client.patch( + f"/api/v1/tests/{test_id}", + json={"name": "New Name"}, + headers=red_tech_headers, + ) + assert response.status_code == 400 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..d90f123 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,385 @@ +# Aegis API Documentation + +This document provides an overview of the Aegis REST API. For interactive documentation with request/response examples, visit: + +- **Swagger UI**: http://localhost:8000/docs +- **ReDoc**: http://localhost:8000/redoc + +## Base URL + +All API endpoints are prefixed with `/api/v1`. + +## Authentication + +The API uses JWT (JSON Web Token) for authentication. Include the token in the `Authorization` header: + +``` +Authorization: Bearer +``` + +### Obtaining a Token + +```bash +curl -X POST http://localhost:8000/api/v1/auth/login \ + -d "username=admin&password=admin123" +``` + +Response: +```json +{ + "access_token": "eyJ...", + "token_type": "bearer" +} +``` + +## Endpoints + +### Authentication + +#### POST /auth/login +Authenticate and receive a JWT token. + +**Request** (form data): +- `username`: string +- `password`: string + +**Response** (200): +```json +{ + "access_token": "string", + "token_type": "bearer" +} +``` + +#### GET /auth/me +Get current authenticated user. + +**Headers**: `Authorization: Bearer ` + +**Response** (200): +```json +{ + "id": "uuid", + "username": "string", + "email": "string|null", + "role": "string", + "is_active": true +} +``` + +--- + +### Techniques + +#### GET /techniques +List all techniques with optional filters. + +**Query Parameters**: +- `tactic` (optional): Filter by tactic name +- `status` (optional): Filter by status (`not_evaluated`, `in_progress`, `validated`, `partial`, `not_covered`) +- `review_required` (optional): Filter by review flag (`true`/`false`) + +**Response** (200): +```json +[ + { + "id": "uuid", + "mitre_id": "T1059", + "name": "Command and Scripting Interpreter", + "tactic": "execution", + "status_global": "validated" + } +] +``` + +#### GET /techniques/{mitre_id} +Get a single technique with associated tests. + +**Response** (200): +```json +{ + "id": "uuid", + "mitre_id": "T1059", + "name": "Command and Scripting Interpreter", + "description": "...", + "tactic": "execution", + "platforms": ["windows", "linux"], + "status_global": "validated", + "review_required": false, + "tests": [...] +} +``` + +#### POST /techniques +Create a new technique. **Admin only.** + +**Request**: +```json +{ + "mitre_id": "T1059", + "name": "Command and Scripting Interpreter", + "description": "...", + "tactic": "execution", + "platforms": ["windows", "linux"] +} +``` + +#### PATCH /techniques/{mitre_id} +Update a technique. **Admin only.** + +**Request**: +```json +{ + "name": "Updated Name", + "status_global": "validated" +} +``` + +#### PATCH /techniques/{mitre_id}/review +Mark a technique as reviewed. **Leads/Admin only.** + +--- + +### Tests + +#### POST /tests +Create a new test. **Red Tech/Admin only.** + +**Request**: +```json +{ + "technique_id": "uuid", + "name": "My Test", + "description": "Test description", + "platform": "windows", + "procedure_text": "Step by step...", + "tool_used": "Atomic Red Team" +} +``` + +#### GET /tests/{id} +Get a test with associated evidences. + +#### PATCH /tests/{id} +Update a test. **Creator/Admin only.** Only allowed when state is `draft` or `rejected`. + +#### POST /tests/{id}/validate +Validate a test. **Leads/Admin only.** + +**Request**: +```json +{ + "result": "detected", + "comments": "Optional comment" +} +``` + +Valid results: `detected`, `not_detected`, `partially_detected` + +#### POST /tests/{id}/reject +Reject a test. **Leads/Admin only.** + +--- + +### Evidence + +#### POST /tests/{test_id}/evidence +Upload an evidence file. + +**Request**: `multipart/form-data` with `file` field. + +**Response** (201): +```json +{ + "id": "uuid", + "test_id": "uuid", + "file_name": "screenshot.png", + "sha256_hash": "abc123...", + "uploaded_at": "2024-01-01T00:00:00Z", + "download_url": "https://..." +} +``` + +#### GET /evidence/{id} +Get evidence metadata with presigned download URL. + +--- + +### Metrics + +#### GET /metrics/summary +Get global coverage summary. + +**Response** (200): +```json +{ + "total_techniques": 200, + "validated": 50, + "partial": 20, + "not_covered": 30, + "in_progress": 10, + "not_evaluated": 90, + "coverage_percentage": 35.0 +} +``` + +#### GET /metrics/by-tactic +Get coverage breakdown by tactic. + +**Response** (200): +```json +[ + { + "tactic": "execution", + "total": 15, + "validated": 5, + "partial": 2, + "not_covered": 3, + "not_evaluated": 5, + "in_progress": 0 + } +] +``` + +--- + +### Users (Admin Only) + +#### GET /users +List all users. + +#### POST /users +Create a new user. + +**Request**: +```json +{ + "username": "newuser", + "email": "user@example.com", + "password": "securepassword", + "role": "red_tech" +} +``` + +Valid roles: `admin`, `red_tech`, `blue_tech`, `red_lead`, `blue_lead`, `viewer` + +#### PATCH /users/{id} +Update a user. + +**Request**: +```json +{ + "email": "new@email.com", + "role": "blue_tech", + "is_active": false, + "password": "newpassword" +} +``` + +--- + +### Audit Logs (Admin Only) + +#### GET /audit-logs +List audit logs with pagination and filters. + +**Query Parameters**: +- `user_id` (optional): Filter by user UUID +- `action` (optional): Filter by action type +- `entity_type` (optional): Filter by entity type +- `start_date` (optional): Filter from date (ISO format) +- `end_date` (optional): Filter to date (ISO format) +- `offset` (optional): Pagination offset (default: 0) +- `limit` (optional): Page size (default: 50, max: 100) + +**Response** (200): +```json +{ + "items": [ + { + "id": "uuid", + "user_id": "uuid", + "username": "admin", + "action": "create_technique", + "entity_type": "technique", + "entity_id": "uuid", + "timestamp": "2024-01-01T00:00:00Z", + "details": {"mitre_id": "T1059"} + } + ], + "total": 100, + "offset": 0, + "limit": 50 +} +``` + +#### GET /audit-logs/actions +List distinct action types. + +#### GET /audit-logs/entity-types +List distinct entity types. + +--- + +### System (Admin Only) + +#### POST /system/sync-mitre +Trigger MITRE ATT&CK synchronization. + +**Response** (200): +```json +{ + "message": "MITRE sync completed", + "new": 5, + "updated": 10 +} +``` + +#### POST /system/run-intel-scan +Trigger threat intelligence scan. + +**Response** (200): +```json +{ + "message": "Intel scan completed", + "new_items": 3 +} +``` + +#### GET /system/scheduler-status +Get background scheduler status. + +**Response** (200): +```json +{ + "running": true, + "jobs": [ + { + "id": "mitre_sync", + "name": "MITRE ATT&CK Sync", + "next_run_time": "2024-01-02T00:00:00Z" + } + ] +} +``` + +--- + +## Error Responses + +All errors follow a consistent format: + +```json +{ + "detail": "Error message", + "code": "ERROR_CODE" +} +``` + +Common HTTP status codes: +- `400` - Bad Request (validation error, invalid input) +- `401` - Unauthorized (missing or invalid token) +- `403` - Forbidden (insufficient permissions) +- `404` - Not Found (resource doesn't exist) +- `409` - Conflict (duplicate resource) +- `500` - Internal Server Error diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2404916..2aacb6c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,8 @@ import TestsPage from "./pages/TestsPage"; import TestCreatePage from "./pages/TestCreatePage"; import TestDetailPage from "./pages/TestDetailPage"; import SystemPage from "./pages/SystemPage"; +import UsersPage from "./pages/UsersPage"; +import AuditLogPage from "./pages/AuditLogPage"; import Layout from "./components/Layout"; import ProtectedRoute from "./components/ProtectedRoute"; @@ -38,6 +40,22 @@ export default function App() { } /> + + + + } + /> + + + + } + /> {/* Catch-all → dashboard */} diff --git a/frontend/src/api/audit.ts b/frontend/src/api/audit.ts new file mode 100644 index 0000000..915634c --- /dev/null +++ b/frontend/src/api/audit.ts @@ -0,0 +1,58 @@ +import client from "./client"; + +export interface AuditLogOut { + id: string; + user_id: string | null; + username: string | null; + action: string; + entity_type: string | null; + entity_id: string | null; + timestamp: string; + details: Record | null; +} + +export interface AuditLogPage { + items: AuditLogOut[]; + total: number; + offset: number; + limit: number; +} + +export interface AuditLogFilters { + user_id?: string; + action?: string; + entity_type?: string; + start_date?: string; + end_date?: string; + offset?: number; + limit?: number; +} + +/** Fetch paginated audit logs with filters. */ +export async function getAuditLogs(filters?: AuditLogFilters): Promise { + const params = new URLSearchParams(); + if (filters?.user_id) params.append("user_id", filters.user_id); + if (filters?.action) params.append("action", filters.action); + if (filters?.entity_type) params.append("entity_type", filters.entity_type); + if (filters?.start_date) params.append("start_date", filters.start_date); + if (filters?.end_date) params.append("end_date", filters.end_date); + if (filters?.offset !== undefined) params.append("offset", String(filters.offset)); + if (filters?.limit !== undefined) params.append("limit", String(filters.limit)); + + const { data } = await client.get( + `/audit-logs${params.toString() ? `?${params}` : ""}` + ); + return data; +} + +/** Fetch list of distinct actions. */ +export async function getAuditActions(): Promise { + const { data } = await client.get("/audit-logs/actions"); + return data; +} + +/** Fetch list of distinct entity types. */ +export async function getAuditEntityTypes(): Promise { + const { data } = await client.get("/audit-logs/entity-types"); + return data; +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 91e87d8..44ff2f4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -import axios from "axios"; +import axios, { type AxiosError } from "axios"; const client = axios.create({ baseURL: "http://localhost:8000/api/v1", @@ -14,14 +14,33 @@ client.interceptors.request.use((config) => { return config; }); -// On 401, clear token so the UI can redirect to login +// Response interceptor for error handling client.interceptors.response.use( (response) => response, - (error) => { - if (error.response?.status === 401) { + (error: AxiosError<{ detail?: string; code?: string }>) => { + const status = error.response?.status; + + // On 401, clear token and redirect to login + if (status === 401) { localStorage.removeItem("token"); + // Only redirect if not already on login page + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } } - return Promise.reject(error); + + // Extract error message from response + const message = + error.response?.data?.detail || + error.message || + "An unexpected error occurred"; + + // Create a more descriptive error + const enhancedError = new Error(message); + (enhancedError as Error & { status?: number; code?: string }).status = status; + (enhancedError as Error & { code?: string }).code = error.response?.data?.code; + + return Promise.reject(enhancedError); }, ); diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..de051a1 --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,43 @@ +import client from "./client"; + +export interface UserOut { + id: string; + username: string; + email: string | null; + role: string; + is_active: boolean; + created_at: string | null; + last_login: string | null; +} + +export interface UserCreatePayload { + username: string; + email?: string; + password: string; + role: string; +} + +export interface UserUpdatePayload { + email?: string; + role?: string; + is_active?: boolean; + password?: string; +} + +/** Fetch all users (admin only). */ +export async function getUsers(): Promise { + const { data } = await client.get("/users"); + return data; +} + +/** Create a new user (admin only). */ +export async function createUser(payload: UserCreatePayload): Promise { + const { data } = await client.post("/users", payload); + return data; +} + +/** Update a user (admin only). */ +export async function updateUser(userId: string, payload: UserUpdatePayload): Promise { + const { data } = await client.patch(`/users/${userId}`, payload); + return data; +} diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..d5cfc37 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,101 @@ +import { Component, type ReactNode, type ErrorInfo } from "react"; +import { AlertTriangle, RefreshCw, Home } from "lucide-react"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; +} + +export default class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("ErrorBoundary caught an error:", error, errorInfo); + this.setState({ errorInfo }); + } + + handleReload = () => { + window.location.reload(); + }; + + handleGoHome = () => { + window.location.href = "/dashboard"; + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+
+
+ +
+ +

+ Something went wrong +

+ +

+ An unexpected error occurred. Please try refreshing the page or + return to the dashboard. +

+ + {process.env.NODE_ENV === "development" && this.state.error && ( +
+ + Error details + +
+                    {this.state.error.toString()}
+                    {this.state.errorInfo?.componentStack}
+                  
+
+ )} + +
+ + +
+
+
+
+ ); + } + + return this.props.children; + } +} diff --git a/frontend/src/components/ErrorMessage.tsx b/frontend/src/components/ErrorMessage.tsx new file mode 100644 index 0000000..cfbead4 --- /dev/null +++ b/frontend/src/components/ErrorMessage.tsx @@ -0,0 +1,40 @@ +import { AlertCircle, RefreshCw } from "lucide-react"; + +interface ErrorMessageProps { + title?: string; + message?: string; + onRetry?: () => void; + fullHeight?: boolean; +} + +export default function ErrorMessage({ + title = "Something went wrong", + message = "An error occurred while loading this content.", + onRetry, + fullHeight = true, +}: ErrorMessageProps) { + return ( +
+
+ +
+
+

{title}

+

{message}

+
+ {onRetry && ( + + )} +
+ ); +} diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000..2fafd1c --- /dev/null +++ b/frontend/src/components/LoadingSpinner.tsx @@ -0,0 +1,36 @@ +import { Loader2 } from "lucide-react"; + +interface LoadingSpinnerProps { + size?: "sm" | "md" | "lg"; + text?: string; + fullScreen?: boolean; +} + +const sizeClasses = { + sm: "h-4 w-4", + md: "h-8 w-8", + lg: "h-12 w-12", +}; + +export default function LoadingSpinner({ + size = "md", + text, + fullScreen = false, +}: LoadingSpinnerProps) { + const content = ( +
+ + {text &&

{text}

} +
+ ); + + if (fullScreen) { + return ( +
+ {content} +
+ ); + } + + return
{content}
; +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index f3f15d5..442e782 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -4,6 +4,8 @@ import { Shield, FlaskConical, Settings, + Users, + FileText, } from "lucide-react"; import { useAuth } from "../context/AuthContext"; @@ -14,6 +16,8 @@ const baseLinks = [ ]; const adminLinks = [ + { to: "/users", label: "Users", icon: Users }, + { to: "/audit", label: "Audit Log", icon: FileText }, { to: "/system", label: "System", icon: Settings }, ]; diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..94309fa --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,88 @@ +import { useState, useEffect, createContext, useContext, useCallback, type ReactNode } from "react"; +import { CheckCircle, AlertCircle, Info, X } from "lucide-react"; + +type ToastType = "success" | "error" | "info"; + +interface Toast { + id: string; + type: ToastType; + message: string; +} + +interface ToastContextValue { + showToast: (type: ToastType, message: string) => void; +} + +const ToastContext = createContext(undefined); + +export function useToast() { + const context = useContext(ToastContext); + if (!context) { + throw new Error("useToast must be used within a ToastProvider"); + } + return context; +} + +const icons = { + success: CheckCircle, + error: AlertCircle, + info: Info, +}; + +const colors = { + success: "border-green-500/30 bg-green-900/30 text-green-400", + error: "border-red-500/30 bg-red-900/30 text-red-400", + info: "border-cyan-500/30 bg-cyan-900/30 text-cyan-400", +}; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + + const showToast = useCallback((type: ToastType, message: string) => { + const id = Math.random().toString(36).substr(2, 9); + setToasts((prev) => [...prev, { id, type, message }]); + }, []); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, []); + + return ( + + {children} +
+ {toasts.map((toast) => ( + removeToast(toast.id)} + /> + ))} +
+
+ ); +} + +function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { + const Icon = icons[toast.type]; + + useEffect(() => { + const timer = setTimeout(onClose, 5000); + return () => clearTimeout(timer); + }, [onClose]); + + return ( +
+ +

{toast.message}

+ +
+ ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 5b7bd3a..a5fbb66 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,8 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AuthProvider } from "./context/AuthContext"; +import { ToastProvider } from "./components/Toast"; +import ErrorBoundary from "./components/ErrorBoundary"; import App from "./App"; import "./index.css"; @@ -14,12 +16,16 @@ const queryClient = new QueryClient({ createRoot(document.getElementById("root")!).render( - - - - - - - + + + + + + + + + + + , ); diff --git a/frontend/src/pages/AuditLogPage.tsx b/frontend/src/pages/AuditLogPage.tsx new file mode 100644 index 0000000..c01ab7d --- /dev/null +++ b/frontend/src/pages/AuditLogPage.tsx @@ -0,0 +1,293 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Loader2, + AlertCircle, + FileText, + Filter, + ChevronLeft, + ChevronRight, + X, +} from "lucide-react"; +import { + getAuditLogs, + getAuditActions, + getAuditEntityTypes, + type AuditLogFilters, +} from "../api/audit"; + +const PAGE_SIZE = 25; + +export default function AuditLogPage() { + const [filters, setFilters] = useState({ + offset: 0, + limit: PAGE_SIZE, + }); + + const { + data: logsData, + isLoading: logsLoading, + error: logsError, + } = useQuery({ + queryKey: ["audit-logs", filters], + queryFn: () => getAuditLogs(filters), + }); + + const { data: actions } = useQuery({ + queryKey: ["audit-actions"], + queryFn: getAuditActions, + }); + + const { data: entityTypes } = useQuery({ + queryKey: ["audit-entity-types"], + queryFn: getAuditEntityTypes, + }); + + const handleFilterChange = (key: keyof AuditLogFilters, value: string) => { + setFilters((prev) => ({ + ...prev, + [key]: value || undefined, + offset: 0, // Reset pagination on filter change + })); + }; + + const clearFilters = () => { + setFilters({ offset: 0, limit: PAGE_SIZE }); + }; + + const goToPage = (newOffset: number) => { + setFilters((prev) => ({ ...prev, offset: newOffset })); + }; + + const hasActiveFilters = filters.action || filters.entity_type || filters.start_date || filters.end_date; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + }; + + const formatDetails = (details: Record | null) => { + if (!details) return null; + return JSON.stringify(details, null, 2); + }; + + const totalPages = logsData ? Math.ceil(logsData.total / PAGE_SIZE) : 0; + const currentPage = Math.floor((filters.offset || 0) / PAGE_SIZE) + 1; + + if (logsLoading) { + return ( +
+ +
+ ); + } + + if (logsError) { + return ( +
+ +

Failed to load audit logs

+
+ ); + } + + return ( +
+ {/* Header */} +
+

Audit Log

+

+ System activity and change history +

+
+ + {/* Filters */} +
+
+ + Filters: +
+ + {/* Action filter */} + + + {/* Entity type filter */} + + + {/* Date range */} + + handleFilterChange( + "start_date", + e.target.value ? `${e.target.value}T00:00:00` : "" + ) + } + className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none" + placeholder="Start date" + /> + to + + handleFilterChange( + "end_date", + e.target.value ? `${e.target.value}T23:59:59` : "" + ) + } + className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none" + placeholder="End date" + /> + + {hasActiveFilters && ( + + )} + +
+ {logsData?.total || 0} total records +
+
+ + {/* Logs Table */} +
+
+ + + + + + + + + + + + {logsData?.items.map((log) => ( + + + + + + + + ))} + +
TimestampUserActionEntityDetails
+ + {formatDate(log.timestamp)} + + + + {log.username || "System"} + + + + {log.action.replace(/_/g, " ")} + + + {log.entity_type && ( +
+ {log.entity_type} + {log.entity_id && ( + + ({log.entity_id.slice(0, 8)}...) + + )} +
+ )} + {!log.entity_type && } +
+ {log.details ? ( +
+ + View details + +
+                          {formatDetails(log.details)}
+                        
+
+ ) : ( + + )} +
+ {logsData?.items.length === 0 && ( +
+ No audit logs found +
+ )} +
+ + {/* Pagination */} + {logsData && logsData.total > PAGE_SIZE && ( +
+
+ Showing {(filters.offset || 0) + 1} to{" "} + {Math.min((filters.offset || 0) + PAGE_SIZE, logsData.total)} of{" "} + {logsData.total} +
+
+ + + Page {currentPage} of {totalPages} + + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx new file mode 100644 index 0000000..66bd1a8 --- /dev/null +++ b/frontend/src/pages/UsersPage.tsx @@ -0,0 +1,472 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Loader2, + AlertCircle, + Users, + Plus, + X, + Check, + UserX, + UserCheck, + Edit, +} from "lucide-react"; +import { getUsers, createUser, updateUser, type UserOut, type UserCreatePayload } from "../api/users"; + +const ROLES = [ + { value: "viewer", label: "Viewer" }, + { value: "red_tech", label: "Red Tech" }, + { value: "blue_tech", label: "Blue Tech" }, + { value: "red_lead", label: "Red Lead" }, + { value: "blue_lead", label: "Blue Lead" }, + { value: "admin", label: "Admin" }, +]; + +const roleBadgeColors: Record = { + admin: "bg-purple-900/50 text-purple-400 border-purple-500/30", + red_tech: "bg-red-900/50 text-red-400 border-red-500/30", + blue_tech: "bg-blue-900/50 text-blue-400 border-blue-500/30", + red_lead: "bg-orange-900/50 text-orange-400 border-orange-500/30", + blue_lead: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30", + viewer: "bg-gray-800/50 text-gray-400 border-gray-600/30", +}; + +export default function UsersPage() { + const queryClient = useQueryClient(); + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingUser, setEditingUser] = useState(null); + + const { + data: users, + isLoading, + error, + } = useQuery({ + queryKey: ["users"], + queryFn: getUsers, + }); + + const createMutation = useMutation({ + mutationFn: createUser, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + setShowCreateModal(false); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ userId, payload }: { userId: string; payload: Parameters[1] }) => + updateUser(userId, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + setEditingUser(null); + }, + }); + + const toggleUserActive = (user: UserOut) => { + updateMutation.mutate({ + userId: user.id, + payload: { is_active: !user.is_active }, + }); + }; + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return "Never"; + return new Date(dateStr).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +

Failed to load users

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

User Management

+

+ Manage user accounts and permissions +

+
+ +
+ + {/* Users Table */} +
+
+ + + + + + + + + + + + + + {users?.map((user) => ( + + + + + + + + + + ))} + +
UsernameEmailRoleStatusCreatedLast LoginActions
+ {user.username} + + {user.email || "—"} + + + {user.role.replace(/_/g, " ")} + + + {user.is_active ? ( + + + Active + + ) : ( + + + Inactive + + )} + + {formatDate(user.created_at)} + + {formatDate(user.last_login)} + +
+ + +
+
+ {users?.length === 0 && ( +
+ No users found +
+ )} +
+
+ + {/* Create User Modal */} + {showCreateModal && ( + setShowCreateModal(false)} + onSubmit={(data) => createMutation.mutate(data)} + isSubmitting={createMutation.isPending} + error={createMutation.error as Error | null} + /> + )} + + {/* Edit User Modal */} + {editingUser && ( + setEditingUser(null)} + onSubmit={(payload) => + updateMutation.mutate({ userId: editingUser.id, payload }) + } + isSubmitting={updateMutation.isPending} + error={updateMutation.error as Error | null} + /> + )} +
+ ); +} + +// ── Create User Modal ────────────────────────────────────────────── + +interface CreateUserModalProps { + onClose: () => void; + onSubmit: (data: UserCreatePayload) => void; + isSubmitting: boolean; + error: Error | null; +} + +function CreateUserModal({ onClose, onSubmit, isSubmitting, error }: CreateUserModalProps) { + const [formData, setFormData] = useState({ + username: "", + email: "", + password: "", + role: "viewer", + }); + const [errors, setErrors] = useState>({}); + + const validate = () => { + const newErrors: Record = {}; + if (!formData.username.trim()) newErrors.username = "Username is required"; + if (!formData.password) newErrors.password = "Password is required"; + if (formData.password.length < 6) newErrors.password = "Password must be at least 6 characters"; + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (validate()) { + onSubmit({ + username: formData.username, + email: formData.email || undefined, + password: formData.password, + role: formData.role, + }); + } + }; + + return ( +
+
+
+

Create New User

+ +
+ + {error && ( +
+

{error.message}

+
+ )} + +
+
+ + setFormData({ ...formData, username: e.target.value })} + className={`mt-1 w-full rounded-lg border bg-gray-800 px-3 py-2 text-gray-200 ${ + errors.username ? "border-red-500" : "border-gray-700" + }`} + /> + {errors.username &&

{errors.username}

} +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200" + /> +
+ +
+ + setFormData({ ...formData, password: e.target.value })} + className={`mt-1 w-full rounded-lg border bg-gray-800 px-3 py-2 text-gray-200 ${ + errors.password ? "border-red-500" : "border-gray-700" + }`} + /> + {errors.password &&

{errors.password}

} +
+ +
+ + +
+ +
+ + +
+
+
+
+ ); +} + +// ── Edit User Modal ────────────────────────────────────────────── + +interface EditUserModalProps { + user: UserOut; + onClose: () => void; + onSubmit: (payload: Parameters[1]) => void; + isSubmitting: boolean; + error: Error | null; +} + +function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUserModalProps) { + const [formData, setFormData] = useState({ + email: user.email || "", + role: user.role, + password: "", + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const payload: Parameters[1] = { + email: formData.email || undefined, + role: formData.role, + }; + if (formData.password) { + payload.password = formData.password; + } + onSubmit(payload); + }; + + return ( +
+
+
+

Edit User: {user.username}

+ +
+ + {error && ( +
+

{error.message}

+
+ )} + +
+
+ + setFormData({ ...formData, email: e.target.value })} + className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200" + /> +
+ +
+ + +
+ +
+ + setFormData({ ...formData, password: e.target.value })} + className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200" + placeholder="••••••••" + /> +
+ +
+ + +
+
+
+
+ ); +}