From aa69aff727ae3a0ac7519fce26c8fb901ea5dac4 Mon Sep 17 00:00:00 2001 From: DeepZone Date: Wed, 20 May 2026 19:54:28 +0100 Subject: [PATCH] Add v0.6.0-beta auth, users, roles foundation --- .env.example | 6 ++ README.md | 2 +- RELEASE_NOTES.md | 2 +- .../0002_users_and_report_ownership.py | 57 ++++++++++++++ backend/app/api/routes_auth.py | 59 ++++++++++++++ backend/app/api/routes_checks.py | 23 +++--- backend/app/api/routes_reports.py | 34 +++----- backend/app/api/routes_system.py | 7 +- backend/app/api/routes_users.py | 51 ++++++++++++ backend/app/config.py | 4 + backend/app/core/auth.py | 78 +++++++++++++++++++ backend/app/core/security.py | 35 +++++++++ backend/app/core/system_status.py | 6 +- backend/app/main.py | 6 +- backend/app/models.py | 30 ++++++- backend/pyproject.toml | 4 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/App.tsx | 6 +- frontend/src/components/Layout.tsx | 2 +- 20 files changed, 366 insertions(+), 52 deletions(-) create mode 100644 backend/alembic/versions/0002_users_and_report_ownership.py create mode 100644 backend/app/api/routes_auth.py create mode 100644 backend/app/api/routes_users.py create mode 100644 backend/app/core/auth.py create mode 100644 backend/app/core/security.py 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 {active === 'dashboard' &&
-

RouteForge v0.5.5-beta

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

+

RouteForge v0.6.0-beta

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

System Health

{systemStatusError ?

{systemStatusError}

:
Status: {systemStatus?.status || 'unknown'}
Mode: {systemStatus?.mode || 'unknown'}
Database: {systemStatus?.database?.status || 'unknown'}
Version: {systemStatus?.version || 'unknown'}
}
} {active === 'asn' && } @@ -49,6 +49,6 @@ export default function App() {

Features

{JSON.stringify(systemStatus.features, null, 2)}
} } - {active === 'about' &&

RouteForge liefert nachvollziehbare Routing-Preflightchecks für technische Operator-Workflows.

Version: v0.5.5-beta

} + {active === 'about' &&

RouteForge liefert nachvollziehbare Routing-Preflightchecks für technische Operator-Workflows.

Version: v0.6.0-beta

}
} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 746401e..ee80535 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -34,7 +34,7 @@ export function Layout({ children, active, onNav, systemLine, title, demoMode }:
{demoMode ? 'DEMO' : 'LIVE'} READ-ONLY - v0.5.5-beta + v0.6.0-beta
{children}