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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ RIPESTAT_USE_STALE_CACHE_ON_ERROR=true

# Optional general HTTP timeout for backend calls
HTTP_TIMEOUT_SECONDS=10

# Auth
SECRET_KEY=change-me
SESSION_COOKIE_NAME=routeforge_session
SESSION_EXPIRE_HOURS=12
COOKIE_SECURE=false
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
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.5.5-beta**.
RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.6.0-beta**.

## Quickstart with Docker Compose

Expand Down
2 changes: 1 addition & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Release Notes

## v0.5.5-beta
## v0.6.0-beta

**SQLite Volume Permission Hotfix**

Expand Down
57 changes: 57 additions & 0 deletions backend/alembic/versions/0002_users_and_report_ownership.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""users and report ownership

Revision ID: 0002_users_and_report_ownership
Revises: 0001_initial_schema
"""
from alembic import op
import sqlalchemy as sa

revision = "0002_users_and_report_ownership"
down_revision = "0001_initial_schema"
branch_labels = None
depends_on = None

def upgrade() -> None:
op.create_table("users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("username", sa.String(length=100), nullable=False, unique=True),
sa.Column("email", sa.String(length=255), nullable=True, unique=True),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("role", sa.String(length=20), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("last_login_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_users_username", "users", ["username"], unique=True)
op.create_index("ix_users_email", "users", ["email"], unique=True)
with op.batch_alter_table("checks") as batch:
batch.add_column(sa.Column("created_by_user_id", sa.Integer(), nullable=True))
batch.create_foreign_key("fk_checks_created_by_user_id", "users", ["created_by_user_id"], ["id"])
with op.batch_alter_table("reports") as batch:
batch.add_column(sa.Column("created_by_user_id", sa.Integer(), nullable=True))
batch.create_foreign_key("fk_reports_created_by_user_id", "users", ["created_by_user_id"], ["id"])
op.create_table("audit_log",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("action", sa.String(length=100), nullable=False),
sa.Column("target_type", sa.String(length=50), nullable=True),
sa.Column("target_id", sa.String(length=100), nullable=True),
sa.Column("ip_address", sa.String(length=64), nullable=True),
sa.Column("user_agent", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("details_json", sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
)

def downgrade() -> None:
op.drop_table("audit_log")
with op.batch_alter_table("reports") as batch:
batch.drop_constraint("fk_reports_created_by_user_id", type_="foreignkey")
batch.drop_column("created_by_user_id")
with op.batch_alter_table("checks") as batch:
batch.drop_constraint("fk_checks_created_by_user_id", type_="foreignkey")
batch.drop_column("created_by_user_id")
op.drop_index("ix_users_email", table_name="users")
op.drop_index("ix_users_username", table_name="users")
op.drop_table("users")
59 changes: 59 additions & 0 deletions backend/app/api/routes_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel
from sqlalchemy.orm import Session

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
from app.models import User

router = APIRouter(prefix="/api/auth", tags=["auth"])

class SetupRequest(BaseModel):
username: str
email: str | None = None
password: str
password_confirm: str

class LoginRequest(BaseModel):
username: str
password: str

@router.get('/setup-required')
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)):
if db.query(User).count() > 0:
raise HTTPException(status_code=403, detail='Setup already completed')
if payload.password != payload.password_confirm:
raise HTTPException(status_code=400, detail='Password confirmation mismatch')
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='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')
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)):
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:
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')
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')
return {"ok": True}

@router.get('/me')
def me(user: User = Depends(require_authenticated_user)):
return {"user": {"id": user.id, "username": user.username, "email": user.email, "role": user.role}}
23 changes: 12 additions & 11 deletions backend/app/api/routes_checks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session

from app.core.auth import require_operator_or_admin
from app.database import get_db
from app.models import Check, Report
from app.schemas import AsnCheckRequest, AsnRpkiBatchRequest, CheckResponse, PrefixCheckRequest, PreflightCheckRequest
Expand All @@ -14,32 +15,32 @@


@router.post('/asn', response_model=CheckResponse)
def check_asn(payload: AsnCheckRequest, db: Session = Depends(get_db)) -> CheckResponse:
def check_asn(payload: AsnCheckRequest, db: Session = Depends(get_db), user=Depends(require_operator_or_admin)) -> CheckResponse:
try:
result = AsnChecker(RipeStatClient(db)).check(payload.asn)
return _store_and_respond(db, "asn", payload.asn, None, result)
return _store_and_respond(db, "asn", payload.asn, None, result, user.id)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"ASN-Prüfung fehlgeschlagen: {exc}") from exc


@router.post('/asn-rpki', response_model=CheckResponse)
def check_asn_rpki(payload: AsnRpkiBatchRequest, db: Session = Depends(get_db)) -> CheckResponse:
def check_asn_rpki(payload: AsnRpkiBatchRequest, db: Session = Depends(get_db), user=Depends(require_operator_or_admin)) -> CheckResponse:
try:
result = AsnChecker(RipeStatClient(db)).check_rpki_batch(payload.asn, payload.limit)
return _store_and_respond(db, "asn-rpki", payload.asn, None, result)
return _store_and_respond(db, "asn-rpki", payload.asn, None, result, user.id)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"ASN-RPKI-Batchprüfung fehlgeschlagen: {exc}") from exc


@router.post('/prefix', response_model=CheckResponse)
def check_prefix(payload: PrefixCheckRequest, db: Session = Depends(get_db)) -> CheckResponse:
def check_prefix(payload: PrefixCheckRequest, db: Session = Depends(get_db), user=Depends(require_operator_or_admin)) -> CheckResponse:
try:
result = PrefixChecker(RipeStatClient(db)).check(payload.prefix, payload.origin_as)
return _store_and_respond(db, "prefix", payload.prefix, payload.origin_as, result)
return _store_and_respond(db, "prefix", payload.prefix, payload.origin_as, result, user.id)
except HTTPException:
raise
except Exception as exc:
Expand All @@ -48,23 +49,23 @@ def check_prefix(payload: PrefixCheckRequest, db: Session = Depends(get_db)) ->


@router.post('/preflight', response_model=CheckResponse)
def check_preflight(payload: PreflightCheckRequest, db: Session = Depends(get_db)) -> CheckResponse:
def check_preflight(payload: PreflightCheckRequest, db: Session = Depends(get_db), user=Depends(require_operator_or_admin)) -> CheckResponse:
try:
result = PreflightChecker(RipeStatClient(db)).check(payload.prefix, payload.planned_origin_as)
return _store_and_respond(db, "preflight", payload.prefix, payload.planned_origin_as, result)
return _store_and_respond(db, "preflight", payload.prefix, payload.planned_origin_as, result, user.id)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Preflight-Prüfung fehlgeschlagen: {exc}") from exc


def _store_and_respond(db: Session, ctype: str, resource: str, origin_as: str | None, result: dict) -> CheckResponse:
check = Check(check_type=ctype, input_resource=resource, origin_as=origin_as, status=result["status"], summary=result["summary"])
def _store_and_respond(db: Session, ctype: str, resource: str, origin_as: str | None, result: dict, user_id: int | None = None) -> CheckResponse:
check = Check(check_type=ctype, input_resource=resource, origin_as=origin_as, status=result["status"], summary=result["summary"], created_by_user_id=user_id)
db.add(check)
db.commit()
db.refresh(check)
report_json, md, html = render_report({"check_id": check.id, "input_check_type": ctype, **result})
report = Report(check_id=check.id, json_data=report_json, markdown=md, html=html)
report = Report(check_id=check.id, created_by_user_id=user_id, json_data=report_json, markdown=md, html=html)
db.add(report)
db.commit()
db.refresh(report)
Expand Down
34 changes: 9 additions & 25 deletions backend/app/api/routes_reports.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import Session

from app.core.auth import require_role
from app.database import get_db
from app.models import Check, Report
from app.services.report_renderer import render_plain_summary

router = APIRouter(prefix="/api/reports", tags=["reports"])



@router.get('')
def list_reports(db: Session = Depends(get_db)):
def list_reports(db: Session = Depends(get_db), _=Depends(require_role('viewer', 'operator', 'admin'))):
rows = (
db.query(Report, Check)
.join(Check, Report.check_id == Check.id)
Expand Down Expand Up @@ -39,38 +39,22 @@ def _report_or_404(db: Session, report_id: int) -> Report:
raise HTTPException(status_code=404, detail="Report not found")
return report


@router.get('/{report_id}')
def get_report(report_id: int, db: Session = Depends(get_db)):
def get_report(report_id: int, db: Session = Depends(get_db), _=Depends(require_role('viewer', 'operator', 'admin'))):
r = _report_or_404(db, report_id)
return r.json_data


@router.get('/{report_id}/markdown')
def get_report_markdown(report_id: int, db: Session = Depends(get_db)):
def get_report_markdown(report_id: int, db: Session = Depends(get_db), _=Depends(require_role('viewer', 'operator', 'admin'))):
r = _report_or_404(db, report_id)
return Response(
content=r.markdown,
media_type="text/markdown; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="routeforge-report-{report_id}.md"'},
)

return Response(content=r.markdown, media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="routeforge-report-{report_id}.md"'})

@router.get('/{report_id}/html')
def get_report_html(report_id: int, db: Session = Depends(get_db)):
def get_report_html(report_id: int, db: Session = Depends(get_db), _=Depends(require_role('viewer', 'operator', 'admin'))):
r = _report_or_404(db, report_id)
return Response(
content=r.html,
media_type="text/html; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="routeforge-report-{report_id}.html"'},
)

return Response(content=r.html, media_type="text/html; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="routeforge-report-{report_id}.html"'})

@router.get('/{report_id}/summary')
def get_report_summary(report_id: int, db: Session = Depends(get_db)):
def get_report_summary(report_id: int, db: Session = Depends(get_db), _=Depends(require_role('viewer', 'operator', 'admin'))):
r = _report_or_404(db, report_id)
return Response(
content=render_plain_summary(r.json_data or {}),
media_type="text/plain; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="routeforge-summary-{report_id}.txt"'},
)
return Response(content=render_plain_summary(r.json_data or {}), media_type="text/plain; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="routeforge-summary-{report_id}.txt"'})
7 changes: 4 additions & 3 deletions backend/app/api/routes_system.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends

from app.config import settings
from app.core.auth import require_role
from app.core.system_status import build_system_status
from app.database import engine

Expand All @@ -11,13 +12,13 @@
def system_info():
return {
'name': 'RouteForge',
'version': 'v0.5.5-beta',
'version': 'v0.6.0-beta',
'demo_mode': settings.demo_mode,
'read_only': True,
'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'],
}


@router.get('/status')
def system_status():
def system_status(_=Depends(require_role('operator', 'admin'))):
return build_system_status(engine)
51 changes: 51 additions & 0 deletions backend/app/api/routes_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session

from app.core.auth import require_admin
from app.core.security import hash_password, validate_password_strength
from app.database import get_db
from app.models import User

router = APIRouter(prefix='/api/users', tags=['users'])

class UserCreate(BaseModel):
username: str
email: str | None = None
password: str
role: str

class UserPatch(BaseModel):
email: str | None = None
role: str | None = None
is_active: bool | None = None
password: str | None = None

@router.get('')
def list_users(_: User = Depends(require_admin), db: Session = Depends(get_db)):
users = db.query(User).order_by(User.id.asc()).all()
return [{"id":u.id,"username":u.username,"email":u.email,"role":u.role,"is_active":u.is_active,"created_at":u.created_at.isoformat()} for u in users]

@router.post('')
def create_user(payload: UserCreate, _: 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)
return {"id":user.id,"username":user.username,"email":user.email,"role":user.role,"is_active":user.is_active}

@router.patch('/{user_id}')
def patch_user(user_id:int,payload:UserPatch,_: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
if payload.role is not None: user.role=payload.role
if payload.is_active is not None: user.is_active=payload.is_active
if payload.password is not None:
errs=validate_password_strength(payload.password)
if errs: raise HTTPException(status_code=400, detail='; '.join(errs))
user.password_hash=hash_password(payload.password)
db.commit(); db.refresh(user)
return {"id":user.id,"username":user.username,"email":user.email,"role":user.role,"is_active":user.is_active}
4 changes: 4 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ class Settings(BaseSettings):
demo_mode: bool = Field(default=False, validation_alias="ROUTEFORGE_DEMO_MODE")
log_level: str = "INFO"
postgres_password: str = Field(default="", validation_alias="POSTGRES_PASSWORD")
secret_key: str = Field(default="change-me", validation_alias="SECRET_KEY")
session_cookie_name: str = Field(default="routeforge_session", validation_alias="SESSION_COOKIE_NAME")
session_expire_hours: int = Field(default=12, validation_alias="SESSION_EXPIRE_HOURS")
cookie_secure: bool = Field(default=False, validation_alias="COOKIE_SECURE")

model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

Expand Down
Loading
Loading