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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<img src="frontend/public/routeforge.png" alt="RouteForge Logo" width="420">
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-v0.6.5--beta-blue" alt="Version">
<img src="https://img.shields.io/badge/version-v0.6.6--beta-blue" alt="Version">
<img src="https://img.shields.io/badge/license-AGPL--3.0--or--later-orange" alt="License">
<img src="https://img.shields.io/badge/status-beta-yellow" alt="Status">
<img src="https://img.shields.io/badge/selfhosted-ready-success" alt="Selfhosted">
Expand Down Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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**
Expand Down
31 changes: 7 additions & 24 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
47 changes: 47 additions & 0 deletions backend/app/api/routes_audit.py
Original file line number Diff line number Diff line change
@@ -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,
}
20 changes: 13 additions & 7 deletions backend/app/api/routes_auth.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -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')
Expand Down
3 changes: 3 additions & 0 deletions backend/app/api/routes_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/routes_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
9 changes: 6 additions & 3 deletions backend/app/api/routes_users.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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}
66 changes: 66 additions & 0 deletions backend/app/core/audit.py
Original file line number Diff line number Diff line change
@@ -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,
)
8 changes: 4 additions & 4 deletions backend/app/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,21 @@
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:
payload = {
"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()
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion backend/app/core/system_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading
Loading