RouteForge v0.5.5-beta
Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.
diff --git a/.env.example b/.env.example
index ce5b74e..f54b3de 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/README.md b/README.md
index 37cc4a4..a01cd71 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index aba5164..02832f7 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,6 +1,6 @@
# Release Notes
-## v0.5.5-beta
+## v0.6.0-beta
**SQLite Volume Permission Hotfix**
diff --git a/backend/alembic/versions/0002_users_and_report_ownership.py b/backend/alembic/versions/0002_users_and_report_ownership.py
new file mode 100644
index 0000000..18faff9
--- /dev/null
+++ b/backend/alembic/versions/0002_users_and_report_ownership.py
@@ -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")
diff --git a/backend/app/api/routes_auth.py b/backend/app/api/routes_auth.py
new file mode 100644
index 0000000..517aedd
--- /dev/null
+++ b/backend/app/api/routes_auth.py
@@ -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}}
diff --git a/backend/app/api/routes_checks.py b/backend/app/api/routes_checks.py
index fdf763a..694df19 100644
--- a/backend/app/api/routes_checks.py
+++ b/backend/app/api/routes_checks.py
@@ -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
@@ -14,10 +15,10 @@
@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:
@@ -25,10 +26,10 @@ def check_asn(payload: AsnCheckRequest, db: Session = Depends(get_db)) -> CheckR
@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:
@@ -36,10 +37,10 @@ def check_asn_rpki(payload: AsnRpkiBatchRequest, db: Session = Depends(get_db))
@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:
@@ -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)
diff --git a/backend/app/api/routes_reports.py b/backend/app/api/routes_reports.py
index 77b44f8..f6482db 100644
--- a/backend/app/api/routes_reports.py
+++ b/backend/app/api/routes_reports.py
@@ -1,6 +1,7 @@
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
@@ -8,9 +9,8 @@
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)
@@ -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"'})
diff --git a/backend/app/api/routes_system.py b/backend/app/api/routes_system.py
index 940de25..7dc234b 100644
--- a/backend/app/api/routes_system.py
+++ b/backend/app/api/routes_system.py
@@ -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
@@ -11,7 +12,7 @@
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'],
@@ -19,5 +20,5 @@ def system_info():
@router.get('/status')
-def system_status():
+def system_status(_=Depends(require_role('operator', 'admin'))):
return build_system_status(engine)
diff --git a/backend/app/api/routes_users.py b/backend/app/api/routes_users.py
new file mode 100644
index 0000000..0d7aba5
--- /dev/null
+++ b/backend/app/api/routes_users.py
@@ -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}
diff --git a/backend/app/config.py b/backend/app/config.py
index 8e0adff..c02635a 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -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")
diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py
new file mode 100644
index 0000000..15435ac
--- /dev/null
+++ b/backend/app/core/auth.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+import base64
+import hashlib
+import hmac
+import json
+from datetime import datetime, timedelta, timezone
+
+from fastapi import Depends, HTTPException, Request
+from sqlalchemy.orm import Session
+
+from app.config import settings
+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()
+
+
+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()),
+ }
+ raw = json.dumps(payload, separators=(",", ":"), sort_keys=True)
+ b64 = base64.urlsafe_b64encode(raw.encode()).decode()
+ return f"{b64}.{_sign(b64)}"
+
+
+def _decode_token(token: str) -> dict:
+ try:
+ b64, sig = token.split('.', 1)
+ except ValueError as exc:
+ raise HTTPException(status_code=401, detail="Invalid session") from exc
+ if not hmac.compare_digest(_sign(b64), sig):
+ raise HTTPException(status_code=401, detail="Invalid session")
+ payload = json.loads(base64.urlsafe_b64decode(b64.encode()).decode())
+ if int(payload.get("exp", 0)) < int(datetime.now(timezone.utc).timestamp()):
+ raise HTTPException(status_code=401, detail="Session expired")
+ return payload
+
+
+def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
+ token = request.cookies.get(settings.session_cookie_name)
+ if not token:
+ raise HTTPException(status_code=401, detail="Not authenticated")
+ payload = _decode_token(token)
+ user = db.query(User).filter(User.id == payload.get("user_id")).first()
+ if not user or not user.is_active:
+ raise HTTPException(status_code=401, detail="User inactive or missing")
+ return user
+
+
+def require_authenticated_user(user: User = Depends(get_current_user)) -> User:
+ return user
+
+
+def require_role(*roles: str):
+ def checker(request: Request, db: Session = Depends(get_db)):
+ if db.query(User).count() == 0:
+ return None
+ current = get_current_user(request, db)
+ if current.role not in roles:
+ raise HTTPException(status_code=403, detail="Insufficient role")
+ return current
+
+ return checker
+
+
+def require_admin(user: User = Depends(require_role("admin"))) -> User:
+ return user
+
+
+def require_operator_or_admin(user: User = Depends(require_role("operator", "admin"))) -> User:
+ return user
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
new file mode 100644
index 0000000..5a520d5
--- /dev/null
+++ b/backend/app/core/security.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+import hashlib
+import hmac
+import os
+import re
+
+
+def hash_password(password: str) -> str:
+ salt = os.urandom(16).hex()
+ digest = hashlib.sha256((salt + password).encode()).hexdigest()
+ return f"sha256${salt}${digest}"
+
+
+def verify_password(password: str, password_hash: str) -> bool:
+ try:
+ _, salt, digest = password_hash.split('$', 2)
+ except ValueError:
+ return False
+ check = hashlib.sha256((salt + password).encode()).hexdigest()
+ return hmac.compare_digest(check, digest)
+
+
+def validate_password_strength(password: str) -> list[str]:
+ errors: list[str] = []
+ if not password or not password.strip():
+ errors.append("Password must not be empty.")
+ return errors
+ if len(password) < 10:
+ errors.append("Password must be at least 10 characters long.")
+ if not re.search(r"[A-Za-z]", password):
+ errors.append("Password must contain at least one letter.")
+ if not re.search(r"\d", password):
+ errors.append("Password must contain at least one number.")
+ return errors
diff --git a/backend/app/core/system_status.py b/backend/app/core/system_status.py
index 9abf794..3dba033 100644
--- a/backend/app/core/system_status.py
+++ b/backend/app/core/system_status.py
@@ -104,8 +104,12 @@ def get_database_status(engine: Engine | None) -> dict:
def _security_warnings() -> list[str]:
warnings: list[str] = []
+ if settings.secret_key == "change-me":
+ warnings.append("SECRET_KEY uses the default example value. Change it before production use.")
if settings.postgres_password == "change-me":
warnings.append("POSTGRES_PASSWORD uses the default example value. Change it before production use.")
+ if not settings.cookie_secure:
+ warnings.append("COOKIE_SECURE is false. Use true behind HTTPS in production.")
return warnings
@@ -113,7 +117,7 @@ def build_system_status(engine: Engine | None) -> dict:
return {
"status": "ok",
"name": settings.app_name,
- "version": "v0.5.5-beta",
+ "version": "v0.6.0-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 22177ff..f5c6566 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -4,10 +4,12 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
+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
from app.api.routes_reports import router as reports_router
from app.api.routes_system import router as system_router
+from app.api.routes_users import router as users_router
from app.config import settings
from app.core.system_status import database_type_from_url
from app.database import Base, engine
@@ -15,7 +17,7 @@
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
logger = logging.getLogger("routeforge")
-app = FastAPI(title="RouteForge", version="0.5.5")
+app = FastAPI(title="RouteForge", version="0.6.0")
app.add_middleware(
CORSMiddleware,
@@ -48,3 +50,5 @@ def startup() -> None:
app.include_router(reports_router)
app.include_router(system_router)
+app.include_router(auth_router)
+app.include_router(users_router)
diff --git a/backend/app/models.py b/backend/app/models.py
index baac55a..aebcb29 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -1,11 +1,24 @@
from datetime import datetime
-from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
+class User(Base):
+ __tablename__ = "users"
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
+ username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
+ email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True, index=True)
+ password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ role: Mapped[str] = mapped_column(String(20), nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+ last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+
class Check(Base):
__tablename__ = "checks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
@@ -14,6 +27,7 @@ class Check(Base):
origin_as: Mapped[str | None] = mapped_column(String(20), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
+ created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
report: Mapped["Report"] = relationship(back_populates="check", uselist=False)
@@ -22,6 +36,7 @@ class Report(Base):
__tablename__ = "reports"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
check_id: Mapped[int] = mapped_column(ForeignKey("checks.id"), nullable=False)
+ created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
json_data: Mapped[dict] = mapped_column(JSON, nullable=False)
markdown: Mapped[str] = mapped_column(Text, nullable=False)
html: Mapped[str] = mapped_column(Text, nullable=False)
@@ -36,3 +51,16 @@ class ApiCache(Base):
response_json: Mapped[dict] = mapped_column(JSON, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
+
+
+class AuditLog(Base):
+ __tablename__ = "audit_log"
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
+ user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
+ action: Mapped[str] = mapped_column(String(100), nullable=False)
+ target_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
+ target_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+ details_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 38206f7..28e7291 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "routeforge-backend"
-version = "0.5.5"
+version = "0.6.0"
description = "RouteForge backend"
license = "AGPL-3.0-or-later"
requires-python = ">=3.12"
@@ -14,6 +14,8 @@ dependencies = [
"alembic>=1.13.0",
"jinja2>=3.1.0",
"typer>=0.12.0",
+ "passlib[bcrypt]>=1.7.4",
+ "python-jose>=3.3.0",
]
[project.optional-dependencies]
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 4036f3b..e1ef36a 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "routeforge-frontend",
- "version": "0.5.5",
+ "version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "routeforge-frontend",
- "version": "0.5.5",
+ "version": "0.6.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"react": "^18.3.1",
diff --git a/frontend/package.json b/frontend/package.json
index 592c2e8..151d409 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "routeforge-frontend",
- "version": "0.5.5",
+ "version": "0.6.0",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6a21d62..18594f2 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -22,14 +22,14 @@ export default function App() {
getSystemStatus().then((payload) => { setSystemStatus(payload); setSystemStatusError('') }).catch(() => setSystemStatusError('System status could not be loaded.'))
}, [])
- const systemLine = useMemo(() => system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.5.5-beta · read-only preflight checks', [system])
+ const systemLine = useMemo(() => system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.6.0-beta · read-only preflight checks', [system])
const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', reports: 'Reports', system: 'System Status', about: 'About RouteForge' }[active]
const proxyStatus = systemStatusError ? 'ERROR' : 'OK'
const migrationStatus = systemStatus?.database?.migration_status || 'unknown'
return Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR. Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR. {systemStatusError} RouteForge liefert nachvollziehbare Routing-Preflightchecks für technische Operator-Workflows. Version: v0.5.5-beta RouteForge liefert nachvollziehbare Routing-Preflightchecks für technische Operator-Workflows. Version: v0.6.0-betaRouteForge v0.5.5-beta
RouteForge v0.6.0-beta
System Health
{systemStatusError ? Features
{JSON.stringify(systemStatus.features, null, 2)}