From 022e02639b6088e81b8964da25f3685f26f1870b Mon Sep 17 00:00:00 2001 From: DeepZone Date: Wed, 20 May 2026 22:00:01 +0100 Subject: [PATCH] v0.6.6-beta: Audit Log UI & Session Hardening --- README.md | 4 +- RELEASE_NOTES.md | 21 ++++++++ ROADMAP.md | 31 +++-------- backend/app/api/routes_audit.py | 47 +++++++++++++++++ backend/app/api/routes_auth.py | 20 ++++--- backend/app/api/routes_checks.py | 3 ++ backend/app/api/routes_system.py | 2 +- backend/app/api/routes_users.py | 9 ++-- backend/app/core/audit.py | 66 ++++++++++++++++++++++++ backend/app/core/auth.py | 8 +-- backend/app/core/system_status.py | 2 +- backend/app/main.py | 4 +- backend/pyproject.toml | 2 +- backend/tests/test_api_smoke.py | 55 ++++++++++++++++++-- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/App.tsx | 14 ++--- frontend/src/api.ts | 14 ++++- frontend/src/components/AuditLogView.tsx | 36 +++++++++++++ frontend/src/components/Layout.tsx | 7 +-- frontend/src/types.ts | 14 +++++ 21 files changed, 305 insertions(+), 60 deletions(-) create mode 100644 backend/app/api/routes_audit.py create mode 100644 backend/app/core/audit.py create mode 100644 frontend/src/components/AuditLogView.tsx diff --git a/README.md b/README.md index e247126..5e59b13 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ RouteForge Logo

- Version + Version License Status Selfhosted @@ -43,7 +43,7 @@ Routing changes often require fast but traceable checks across multiple external ## Current Alpha Status -RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.6.5-beta**. +RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.6.6-beta**. ## Quickstart with Docker Compose diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7bbc10b..bc1930f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,26 @@ # Release Notes +## v0.6.6-beta + +**Audit Log UI & Session Hardening** + +### Highlights + +- Added centralized backend audit logging service and audit events for setup/auth/user/check/report actions. +- Added admin-only `GET /api/audit-log` API with basic filters and newest-first sorting. +- Added admin-only Audit Log UI with table, loading/error/empty states and filters. +- Session cookie handling now consistently uses `SESSION_COOKIE_NAME` and `COOKIE_SECURE` settings for set/delete. +- Added backend tests for audit permissions/events and session cookie hardening behavior. + +### Security notes + +- Audit events exclude passwords, password hashes, session tokens and secrets. +- `login_failed` entries store only username/reason and request metadata. +- RouteForge remains read-only against RIPE DB, RPKI and routers; no write paths were introduced. +- Audit logging is local to RouteForge storage only. + +--- + ## v0.6.5-beta **Auth UX & Admin Console Polish** diff --git a/ROADMAP.md b/ROADMAP.md index 8b2528f..3072aee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,35 +1,18 @@ # RouteForge Roadmap ## Current Status -v0.5.0-beta, beta foundation for production selfhosting, read-only - -## v0.3.x -- demo polish -- export polish -- UI refinements - -## v0.4.0-alpha -- data source hardening -- RIPEstat error classification -- cache visibility -- retry handling - -## v0.5.0-beta -- production Docker Compose -- PostgreSQL hardening -- backup/restore docs -- reverse proxy examples -- healthchecks - -## v0.6.0-beta -- user management -- roles -- audit log +v0.6.6-beta, audit log UI/API and session hardening completed, read-only ## v0.7.0-beta - projects/change cases - grouped preflight reports +## v0.7.1-beta +- bgp visibility details + +## v0.7.2-beta +- roa planner / roa preflight + ## v0.8.0-beta - watch mode / scheduled rechecks diff --git a/backend/app/api/routes_audit.py b/backend/app/api/routes_audit.py new file mode 100644 index 0000000..97e3994 --- /dev/null +++ b/backend/app/api/routes_audit.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.core.auth import require_admin +from app.database import get_db +from app.models import AuditLog, User + +router = APIRouter(prefix='/api/audit-log', tags=['audit']) + +@router.get('') +def list_audit_log( + action: str | None = Query(default=None), + user_id: int | None = Query(default=None), + target_type: str | None = Query(default=None), + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + _: User = Depends(require_admin), + db: Session = Depends(get_db), +): + query = db.query(AuditLog, User.username).outerjoin(User, AuditLog.user_id == User.id) + if action: + query = query.filter(AuditLog.action == action) + if user_id is not None: + query = query.filter(AuditLog.user_id == user_id) + if target_type: + query = query.filter(AuditLog.target_type == target_type) + + rows = query.order_by(AuditLog.created_at.desc(), AuditLog.id.desc()).offset(offset).limit(limit).all() + return { + 'items': [ + { + 'id': audit.id, + 'created_at': audit.created_at.isoformat(), + 'user_id': audit.user_id, + 'username': username, + 'action': audit.action, + 'target_type': audit.target_type, + 'target_id': audit.target_id, + 'ip_address': audit.ip_address, + 'user_agent': audit.user_agent, + 'details_json': audit.details_json, + } + for audit, username in rows + ], + 'limit': limit, + 'offset': offset, + } diff --git a/backend/app/api/routes_auth.py b/backend/app/api/routes_auth.py index 517aedd..1e4876f 100644 --- a/backend/app/api/routes_auth.py +++ b/backend/app/api/routes_auth.py @@ -1,8 +1,10 @@ from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel from sqlalchemy.orm import Session +import app.config as config +from app.core.audit import write_audit_log_for_request from app.core.auth import create_session_token, require_authenticated_user from app.core.security import hash_password, validate_password_strength, verify_password from app.database import get_db @@ -25,7 +27,7 @@ def setup_required(db: Session = Depends(get_db)): return {"setup_required": db.query(User).count() == 0} @router.post('/setup') -def setup(payload: SetupRequest, response: Response, db: Session = Depends(get_db)): +def setup(payload: SetupRequest, request: Request, response: Response, db: Session = Depends(get_db)): if db.query(User).count() > 0: raise HTTPException(status_code=403, detail='Setup already completed') if payload.password != payload.password_confirm: @@ -36,22 +38,26 @@ def setup(payload: SetupRequest, response: Response, db: Session = Depends(get_d user = User(username=payload.username.strip(), email=payload.email, password_hash=hash_password(payload.password), role='admin', is_active=True) db.add(user); db.commit(); db.refresh(user) token = create_session_token(user) - response.set_cookie('routeforge_session', token, httponly=True, samesite='lax') + response.set_cookie(config.settings.session_cookie_name, token, httponly=True, samesite='lax', secure=config.settings.cookie_secure) + write_audit_log_for_request(db, request, action='initial_admin_setup', actor=user, target_type='user', target_id=str(user.id), details_json={'username': user.username, 'role': user.role}) return {"user": {"id": user.id, "username": user.username, "email": user.email, "role": user.role}} @router.post('/login') -def login(payload: LoginRequest, response: Response, db: Session = Depends(get_db)): +def login(payload: LoginRequest, request: Request, response: Response, db: Session = Depends(get_db)): user = db.query(User).filter(User.username == payload.username).first() if not user or not verify_password(payload.password, user.password_hash) or not user.is_active: + write_audit_log_for_request(db, request, action='login_failed', target_type='auth', details_json={'username': payload.username, 'reason': 'invalid_credentials'}) raise HTTPException(status_code=401, detail='Invalid credentials') user.last_login_at = datetime.utcnow(); db.commit() token = create_session_token(user) - response.set_cookie('routeforge_session', token, httponly=True, samesite='lax') + response.set_cookie(config.settings.session_cookie_name, token, httponly=True, samesite='lax', secure=config.settings.cookie_secure) + write_audit_log_for_request(db, request, action='login_success', actor=user, target_type='user', target_id=str(user.id), details_json={'username': user.username}) return {"user": {"id": user.id, "username": user.username, "email": user.email, "role": user.role}} @router.post('/logout') -def logout(response: Response): - response.delete_cookie('routeforge_session') +def logout(request: Request, response: Response, user: User = Depends(require_authenticated_user), db: Session = Depends(get_db)): + write_audit_log_for_request(db, request, action='logout', actor=user, target_type='user', target_id=str(user.id), details_json={'username': user.username}) + response.delete_cookie(config.settings.session_cookie_name, httponly=True, samesite='lax', secure=config.settings.cookie_secure) return {"ok": True} @router.get('/me') diff --git a/backend/app/api/routes_checks.py b/backend/app/api/routes_checks.py index b45faae..424abba 100644 --- a/backend/app/api/routes_checks.py +++ b/backend/app/api/routes_checks.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import Session from app.core.auth import require_operator_or_admin +from app.core.audit import write_audit_log from app.database import get_db from app.models import Check, Report from app.schemas import AsnCheckRequest, AsnRpkiBatchRequest, CheckResponse, PrefixCheckRequest, PreflightCheckRequest @@ -71,6 +72,8 @@ def _store_and_respond(db: Session, ctype: str, resource: str, origin_as: str | db.add(report) db.commit() db.refresh(report) + write_audit_log(db, user_id=user_id, action='check_executed', target_type='check', target_id=str(check.id), details_json={'check_type': ctype, 'input_resource': resource, 'status': result.get('status')}) + write_audit_log(db, user_id=user_id, action='report_generated', target_type='report', target_id=str(report.id), details_json={'check_id': check.id, 'check_type': ctype}) return CheckResponse(report_id=report.id, status=result["status"], summary=result["summary"], explanation=result.get("explanation"), risk=result.get("risk"), recommendations=result["recommendations"], input=result.get("input"), checks=result.get("checks"), details=result["details"], markdown=md, html=html) except OperationalError as exc: db.rollback() diff --git a/backend/app/api/routes_system.py b/backend/app/api/routes_system.py index e397853..b83e513 100644 --- a/backend/app/api/routes_system.py +++ b/backend/app/api/routes_system.py @@ -12,7 +12,7 @@ def system_info(): return { 'name': 'RouteForge', - 'version': 'v0.6.5-beta', + 'version': 'v0.6.6-beta', 'demo_mode': settings.demo_mode, 'read_only': True, 'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'], diff --git a/backend/app/api/routes_users.py b/backend/app/api/routes_users.py index 3ef1927..90b5480 100644 --- a/backend/app/api/routes_users.py +++ b/backend/app/api/routes_users.py @@ -1,7 +1,8 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from sqlalchemy.orm import Session +from app.core.audit import write_audit_log_for_request from app.core.auth import require_admin from app.core.security import hash_password, validate_password_strength from app.database import get_db @@ -27,17 +28,18 @@ def list_users(_: User = Depends(require_admin), db: Session = Depends(get_db)): return [{"id":u.id,"username":u.username,"email":u.email,"role":u.role,"is_active":u.is_active,"created_at":u.created_at.isoformat(),"updated_at":u.updated_at.isoformat(),"last_login_at":u.last_login_at.isoformat() if u.last_login_at else None} for u in users] @router.post('') -def create_user(payload: UserCreate, _: User = Depends(require_admin), db: Session = Depends(get_db)): +def create_user(payload: UserCreate, request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db)): if payload.role not in {'admin','operator','viewer'}: raise HTTPException(status_code=400, detail='Invalid role') errs=validate_password_strength(payload.password) if errs: raise HTTPException(status_code=400, detail='; '.join(errs)) user=User(username=payload.username.strip(), email=payload.email, password_hash=hash_password(payload.password), role=payload.role, is_active=True) db.add(user); db.commit(); db.refresh(user) + write_audit_log_for_request(db, request, action='user_created', actor=admin, target_type='user', target_id=str(user.id), details_json={'username': user.username, 'role': user.role}) return {"id":user.id,"username":user.username,"email":user.email,"role":user.role,"is_active":user.is_active,"created_at":user.created_at.isoformat(),"updated_at":user.updated_at.isoformat(),"last_login_at":user.last_login_at.isoformat() if user.last_login_at else None} @router.patch('/{user_id}') -def patch_user(user_id:int,payload:UserPatch,_:User=Depends(require_admin),db:Session=Depends(get_db)): +def patch_user(user_id:int,payload:UserPatch,request: Request,admin:User=Depends(require_admin),db:Session=Depends(get_db)): user=db.query(User).filter(User.id==user_id).first() if not user: raise HTTPException(status_code=404, detail='User not found') if payload.email is not None: user.email=payload.email @@ -51,4 +53,5 @@ def patch_user(user_id:int,payload:UserPatch,_:User=Depends(require_admin),db:Se if errs: raise HTTPException(status_code=400, detail='; '.join(errs)) user.password_hash=hash_password(payload.password) db.commit(); db.refresh(user) + write_audit_log_for_request(db, request, action='user_updated', actor=admin, target_type='user', target_id=str(user.id), details_json={'username': user.username, 'role': user.role, 'is_active': user.is_active}) return {"id":user.id,"username":user.username,"email":user.email,"role":user.role,"is_active":user.is_active,"created_at":user.created_at.isoformat(),"updated_at":user.updated_at.isoformat(),"last_login_at":user.last_login_at.isoformat() if user.last_login_at else None} diff --git a/backend/app/core/audit.py b/backend/app/core/audit.py new file mode 100644 index 0000000..d16ee2c --- /dev/null +++ b/backend/app/core/audit.py @@ -0,0 +1,66 @@ +import logging +from typing import Any + +from fastapi import Request +from sqlalchemy.orm import Session + +from app.models import AuditLog, User + +logger = logging.getLogger("routeforge.audit") + + +def extract_request_meta(request: Request) -> tuple[str | None, str | None]: + ip_address = request.client.host if request.client else None + user_agent = request.headers.get("user-agent") + return ip_address, user_agent + + +def write_audit_log( + db: Session, + *, + user_id: int | None, + action: str, + target_type: str | None = None, + target_id: str | None = None, + ip_address: str | None = None, + user_agent: str | None = None, + details_json: dict[str, Any] | None = None, +) -> None: + try: + entry = AuditLog( + user_id=user_id, + action=action, + target_type=target_type, + target_id=target_id, + ip_address=ip_address, + user_agent=user_agent, + details_json=details_json, + ) + db.add(entry) + db.commit() + except Exception: + db.rollback() + logger.exception("audit log write failed", extra={"action": action, "target_type": target_type, "target_id": target_id}) + + +def write_audit_log_for_request( + db: Session, + request: Request, + *, + action: str, + actor: User | None = None, + target_type: str | None = None, + target_id: str | None = None, + details_json: dict[str, Any] | None = None, +) -> None: + ip_address, user_agent = extract_request_meta(request) + write_audit_log( + db, + user_id=actor.id if actor else None, + action=action, + target_type=target_type, + target_id=target_id, + ip_address=ip_address, + user_agent=user_agent, + details_json=details_json, + ) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 1eb480d..ce42513 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -9,13 +9,13 @@ from fastapi import Depends, HTTPException, Request from sqlalchemy.orm import Session -from app.config import settings +import app.config as config from app.database import get_db from app.models import User def _sign(data: str) -> str: - return hmac.new(settings.secret_key.encode(), data.encode(), hashlib.sha256).hexdigest() + return hmac.new(config.settings.secret_key.encode(), data.encode(), hashlib.sha256).hexdigest() def create_session_token(user: User) -> str: @@ -23,7 +23,7 @@ def create_session_token(user: User) -> str: "user_id": user.id, "username": user.username, "role": user.role, - "exp": int((datetime.now(timezone.utc) + timedelta(hours=settings.session_expire_hours)).timestamp()), + "exp": int((datetime.now(timezone.utc) + timedelta(hours=config.settings.session_expire_hours)).timestamp()), } raw = json.dumps(payload, separators=(",", ":"), sort_keys=True) b64 = base64.urlsafe_b64encode(raw.encode()).decode() @@ -44,7 +44,7 @@ def _decode_token(token: str) -> dict: def get_current_user(request: Request, db: Session = Depends(get_db)) -> User: - token = request.cookies.get(settings.session_cookie_name) + token = request.cookies.get(config.settings.session_cookie_name) if not token: raise HTTPException(status_code=401, detail="Not authenticated") payload = _decode_token(token) diff --git a/backend/app/core/system_status.py b/backend/app/core/system_status.py index 3ab8f7c..2b23c69 100644 --- a/backend/app/core/system_status.py +++ b/backend/app/core/system_status.py @@ -139,7 +139,7 @@ def build_system_status(engine: Engine | None) -> dict: return { "status": "ok", "name": settings.app_name, - "version": "v0.6.5-beta", + "version": "v0.6.6-beta", "read_only": True, "mode": "demo" if settings.demo_mode else "live", "demo_mode": settings.demo_mode, diff --git a/backend/app/main.py b/backend/app/main.py index 39e7a72..fdfd85d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,6 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from app.api.routes_audit import router as audit_router from app.api.routes_auth import router as auth_router from app.api.routes_checks import router as checks_router from app.api.routes_health import router as health_router @@ -17,7 +18,7 @@ logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO)) logger = logging.getLogger("routeforge") -app = FastAPI(title="RouteForge", version="0.6.5") +app = FastAPI(title="RouteForge", version="0.6.6") app.add_middleware( CORSMiddleware, @@ -52,3 +53,4 @@ def startup() -> None: app.include_router(system_router) app.include_router(auth_router) app.include_router(users_router) +app.include_router(audit_router) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f9a8e1f..13fb7c6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "routeforge-backend" -version = "0.6.5" +version = "0.6.6" description = "RouteForge backend" license = "AGPL-3.0-or-later" requires-python = ">=3.12" diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py index b3f13be..ad9d957 100644 --- a/backend/tests/test_api_smoke.py +++ b/backend/tests/test_api_smoke.py @@ -6,10 +6,10 @@ from fastapi.testclient import TestClient -def _client() -> TestClient: +def _client(**overrides) -> TestClient: import app.config as config - config.settings = config.Settings(_env_file=None, ROUTEFORGE_DEMO_MODE=True) + config.settings = config.Settings(_env_file=None, ROUTEFORGE_DEMO_MODE=True, **overrides) import app.main as main_module import app.database as database @@ -177,7 +177,7 @@ def test_system_status_endpoint() -> None: response = client.get('/api/system/status') assert response.status_code == 200 payload = response.json() - assert payload.get('version') == 'v0.6.5-beta' + assert payload.get('version') == 'v0.6.6-beta' assert payload.get('read_only') is True assert payload.get('database', {}).get('status') assert payload.get('ripestat', {}).get('cache_ttl_seconds') is not None @@ -320,3 +320,52 @@ def test_inactive_user_cannot_login() -> None: client.post('/api/auth/logout') login = client.post('/api/auth/login', json={'username': 'inactive1', 'password': 'InactivePass123!'}) assert login.status_code == 401 + +def test_audit_log_admin_only_and_contains_login_success() -> None: + client = _client() + _setup_and_login(client) + resp = client.get('/api/audit-log') + assert resp.status_code == 200 + actions = [i.get('action') for i in resp.json().get('items', [])] + assert 'login_success' in actions or 'initial_admin_setup' in actions + + +def test_audit_log_forbidden_for_operator_and_viewer() -> None: + client = _client() + _setup_and_login(client) + assert client.post('/api/users', json={'username': 'op3', 'email': 'op3@example.org', 'password': 'OperatorPass123!', 'role': 'operator'}).status_code == 200 + assert client.post('/api/users', json={'username': 'vw3', 'email': 'vw3@example.org', 'password': 'ViewerPass123!', 'role': 'viewer'}).status_code == 200 + client.post('/api/auth/logout') + assert client.post('/api/auth/login', json={'username': 'op3', 'password': 'OperatorPass123!'}).status_code == 200 + assert client.get('/api/audit-log').status_code == 403 + client.post('/api/auth/logout') + assert client.post('/api/auth/login', json={'username': 'vw3', 'password': 'ViewerPass123!'}).status_code == 200 + assert client.get('/api/audit-log').status_code == 403 + + +def test_login_failed_audit_has_no_password() -> None: + client = _client() + _setup_and_login(client) + bad = client.post('/api/auth/login', json={'username': 'admin', 'password': 'wrong'}) + assert bad.status_code == 401 + resp = client.get('/api/audit-log') + events = [i for i in resp.json().get('items', []) if i.get('action') == 'login_failed'] + assert events + assert 'password' not in str(events[0].get('details_json', {})).lower() + + +def test_cookie_name_and_logout_cookie_settings() -> None: + secure_client = _client(SESSION_COOKIE_NAME='rf_cookie_test', COOKIE_SECURE=True) + setup = secure_client.post('/api/auth/setup', json={'username': 'admin', 'email': 'admin@example.org', 'password': 'AdminPass123!', 'password_confirm': 'AdminPass123!'}) + assert setup.status_code == 200 + cookie_header = setup.headers.get('set-cookie', '') + assert 'rf_cookie_test=' in cookie_header + assert 'Secure' in cookie_header + + client = _client(SESSION_COOKIE_NAME='rf_cookie_test', COOKIE_SECURE=False) + setup2 = client.post('/api/auth/setup', json={'username': 'admin', 'email': 'admin@example.org', 'password': 'AdminPass123!', 'password_confirm': 'AdminPass123!'}) + assert setup2.status_code == 200 + out = client.post('/api/auth/logout') + assert out.status_code == 200 + logout_cookie = out.headers.get('set-cookie', '') + assert 'rf_cookie_test=' in logout_cookie diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1e71bec..d7b2272 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "routeforge-frontend", - "version": "0.6.5", + "version": "0.6.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "routeforge-frontend", - "version": "0.6.5", + "version": "0.6.6", "license": "AGPL-3.0-or-later", "dependencies": { "react": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index 324d1af..053bbbc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "routeforge-frontend", - "version": "0.6.5", + "version": "0.6.6", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 34b159a..5621ba5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { ApiError, getMe, getReportHtml, getReportMarkdown, getReportSummary, getReports, getSetupRequired, getSystemInfo, getSystemStatus, login, logout, setupAdmin } from './api' +import { AuditLogView } from './components/AuditLogView' import { AsnCheckForm } from './components/AsnCheckForm' import { Layout } from './components/Layout' import { LoginView } from './components/LoginView' @@ -10,7 +11,7 @@ import { StatusBadge } from './components/StatusBadge' import type { ReportListItem, SystemInfo, SystemStatus, User, UserRole } from './types' import { UsersView } from './components/UsersView' -type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'system' | 'users' | 'about' +type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'system' | 'users' | 'audit' | 'about' type AuthMode = 'loading' | 'setup' | 'login' | 'app' | 'error' export default function App() { @@ -69,8 +70,8 @@ export default function App() { if (authMode === 'login') return if (authMode === 'error') return

{authError}
- const systemLine = system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.6.5-beta · read-only preflight checks' - const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', reports: 'Reports', system: 'System Status', users: 'User Management', about: 'About RouteForge' }[active] + const systemLine = system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.6.6-beta · read-only preflight checks' + const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', reports: 'Reports', system: 'System Status', users: 'User Management', audit: 'Audit Log', about: 'About RouteForge' }[active] const proxyStatus = systemStatusError ? 'ERROR' : 'OK' const migrationStatus = systemStatus?.database?.migration_status || 'unknown' const migrationsBlocked = migrationStatus === 'behind' || migrationStatus === 'error' @@ -78,13 +79,13 @@ export default function App() { const role = (currentUser?.role || 'viewer') as UserRole const canAccess = (view: NavKey) => { if (role === 'admin') return true - if (role === 'operator') return view !== 'users' + if (role === 'operator') return !['users', 'audit'].includes(view) return ['dashboard', 'reports', 'about'].includes(view) } const allowedActions = role === 'admin' ? 'You can run checks, manage users, view reports and system status.' : role === 'operator' ? 'You can run checks and view reports.' : 'You can view reports.' return - {active === 'dashboard' &&

RouteForge v0.6.5-beta

Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.

Logged in as: {currentUser?.username}
Role: {currentUser?.role}
Allowed actions: {allowedActions}
{migrationsBlocked &&
Database migrations are required before using RouteForge.
}
} + {active === 'dashboard' &&

RouteForge v0.6.6-beta

Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.

Logged in as: {currentUser?.username}
Role: {currentUser?.role}
Allowed actions: {allowedActions}
{migrationsBlocked &&
Database migrations are required before using RouteForge.
}
} {!canAccess(active) &&
You do not have permission to access this section.
} {active === 'asn' && canAccess('asn') && } {active === 'prefix' && canAccess('prefix') && } @@ -92,6 +93,7 @@ export default function App() { {active === 'reports' &&

Reports

{reports.length===0 ?
Noch keine Reports vorhanden.
:
{reports.map(r=>)}
{r.summary}
}
} {active === 'system' && canAccess('system') &&
{systemStatusError &&
{systemStatusError}
}{migrationsBlocked &&
Database migrations are required before using RouteForge.
}{systemStatus &&
Version: {systemStatus.version}
Mode: {systemStatus.mode}
API Proxy: {proxyStatus}
Migration Status: {migrationStatus}
}
} {active === 'users' && canAccess('users') && } - {active === 'about' &&

Version: v0.6.5-beta

} + {active === 'audit' && canAccess('audit') && } + {active === 'about' &&

Version: v0.6.6-beta

}
} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index dd61f2c..a009d02 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { CheckResponse, ReportListItem, SystemInfo, SystemStatus, User, UserCreatePayload, UserUpdatePayload } from './types' +import type { AuditLogEntry, CheckResponse, ReportListItem, SystemInfo, SystemStatus, User, UserCreatePayload, UserUpdatePayload } from './types' const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '') @@ -62,3 +62,15 @@ export const logout = () => requestJson<{ ok: boolean }>(apiUrl('/api/auth/logou export const listUsers = () => requestJson(apiUrl('/api/users'), { method: 'GET' }) export const createUser = (payload: UserCreatePayload) => requestJson(apiUrl('/api/users'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) export const updateUser = (userId: number, payload: UserUpdatePayload) => requestJson(apiUrl(`/api/users/${userId}`), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) + + +export const getAuditLog = (params: { action?: string; user_id?: number; target_type?: string; limit?: number; offset?: number }) => { + const search = new URLSearchParams() + if (params.action) search.set('action', params.action) + if (params.user_id !== undefined) search.set('user_id', String(params.user_id)) + if (params.target_type) search.set('target_type', params.target_type) + if (params.limit !== undefined) search.set('limit', String(params.limit)) + if (params.offset !== undefined) search.set('offset', String(params.offset)) + const suffix = search.toString() + return requestJson<{ items: AuditLogEntry[]; limit: number; offset: number }>(apiUrl('/api/audit-log' + (suffix ? `?${suffix}` : '')), { method: 'GET' }) +} diff --git a/frontend/src/components/AuditLogView.tsx b/frontend/src/components/AuditLogView.tsx new file mode 100644 index 0000000..08b58fa --- /dev/null +++ b/frontend/src/components/AuditLogView.tsx @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react' +import { getAuditLog } from '../api' +import type { AuditLogEntry } from '../types' + +export function AuditLogView() { + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [action, setAction] = useState('') + const [targetType, setTargetType] = useState('') + + useEffect(() => { + const run = async () => { + setLoading(true); setError('') + try { + const payload = await getAuditLog({ action: action || undefined, target_type: targetType || undefined, limit: 200 }) + setItems(payload.items || []) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Audit log could not be loaded') + } finally { setLoading(false) } + } + run() + }, [action, targetType]) + + return
+

Audit Log

+
+ setAction(e.target.value)} /> + setTargetType(e.target.value)} /> +
+ {loading &&
Loading audit log…
} + {error &&
{error}
} + {!loading && !error && items.length === 0 &&
No audit events yet.
} + {!loading && !error && items.length > 0 &&
{items.map((a)=>)}
TimeUserActionTargetIPUser AgentDetails
{a.created_at}{a.username || a.user_id || '-'}{a.action}{a.target_type || '-'}:{a.target_id || '-'}{a.ip_address || '-'}{a.user_agent || '-'}
{JSON.stringify(a.details_json || {}, null, 2)}
} +
+} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 20c46d8..4d8e55a 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { ReactNode } from 'react' import type { UserRole } from '../types' -type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'system' | 'users' | 'about' +type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'system' | 'users' | 'audit' | 'about' const nav: { key: NavKey; label: string; desc: string }[] = [ { key: 'dashboard', label: 'Dashboard', desc: 'Overview & quick actions' }, @@ -11,6 +11,7 @@ const nav: { key: NavKey; label: string; desc: string }[] = [ { key: 'reports', label: 'Reports', desc: 'History and outcomes' }, { key: 'system', label: 'System', desc: 'Operational checks' }, { key: 'users', label: 'Users', desc: 'Admin user management' }, + { key: 'audit', label: 'Audit Log', desc: 'Admin audit trail' }, { key: 'about', label: 'About', desc: 'Data sources and limits' }, ] @@ -18,7 +19,7 @@ export function Layout({ children, active, onNav, systemLine, title, demoMode, c const visibleNav = nav.filter((item) => { if (!currentUser) return ['dashboard', 'about'].includes(item.key) if (currentUser.role === 'admin') return true - if (currentUser.role === 'operator') return item.key !== 'users' + if (currentUser.role === 'operator') return !['users', 'audit'].includes(item.key) return ['dashboard', 'reports', 'about'].includes(item.key) }) return
@@ -43,7 +44,7 @@ export function Layout({ children, active, onNav, systemLine, title, demoMode, c {currentUser && Angemeldet als {currentUser.username} · {currentUser.role}} {demoMode ? 'DEMO' : 'LIVE'} READ-ONLY - v0.6.5-beta + v0.6.6-beta
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 1b18e33..c2f2afd 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -118,3 +118,17 @@ export type UserUpdatePayload = { is_active?: boolean password?: string } + + +export type AuditLogEntry = { + id: number + created_at: string + user_id?: number | null + username?: string | null + action: string + target_type?: string | null + target_id?: string | null + ip_address?: string | null + user_agent?: string | null + details_json?: Record | null +}