RouteForge v0.6.6-beta
Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.
diff --git a/README.md b/README.md
index 5e59b13..0647231 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.6.6-beta**.
+RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.7.0-beta**.
## Quickstart with Docker Compose
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index bc1930f..8d70d1a 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,6 +1,13 @@
+## v0.7.0-beta
+- Added Projects / Change Cases lifecycle (draft, in_review, approved, closed).
+- Added Change Case API, UI navigation and detail workflow.
+- Checks and reports can be attached to Change Cases for local RouteForge workflow traceability.
+- Added audit events for Change Case and attachment operations.
+- Security: Change Cases are local workflow metadata only; no writes to RIPE DB, RPKI, or routers are performed.
+
# Release Notes
-## v0.6.6-beta
+## v0.7.0-beta
**Audit Log UI & Session Hardening**
diff --git a/ROADMAP.md b/ROADMAP.md
index 3072aee..44f53cf 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,7 +1,7 @@
# RouteForge Roadmap
## Current Status
-v0.6.6-beta, audit log UI/API and session hardening completed, read-only
+v0.7.0-beta, audit log UI/API and session hardening completed, read-only
## v0.7.0-beta
- projects/change cases
diff --git a/backend/alembic/versions/0003_change_cases.py b/backend/alembic/versions/0003_change_cases.py
new file mode 100644
index 0000000..2b6ec88
--- /dev/null
+++ b/backend/alembic/versions/0003_change_cases.py
@@ -0,0 +1,31 @@
+"""add change cases
+
+Revision ID: 0003_change_cases
+Revises: 0002_users_and_report_ownership
+Create Date: 2026-05-20
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = '0003_change_cases'
+down_revision = '0002_users_and_report_ownership'
+branch_labels = None
+depends_on = None
+
+def upgrade() -> None:
+ op.create_table('change_cases',
+ sa.Column('id', sa.Integer(), primary_key=True),
+ sa.Column('title', sa.String(length=200), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('status', sa.String(length=20), nullable=False, server_default='draft'),
+ sa.Column('created_by_user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ )
+ op.add_column('checks', sa.Column('change_case_id', sa.Integer(), nullable=True))
+ op.create_foreign_key('fk_checks_change_case_id', 'checks', 'change_cases', ['change_case_id'], ['id'])
+
+def downgrade() -> None:
+ op.drop_constraint('fk_checks_change_case_id', 'checks', type_='foreignkey')
+ op.drop_column('checks', 'change_case_id')
+ op.drop_table('change_cases')
diff --git a/backend/app/api/routes_change_cases.py b/backend/app/api/routes_change_cases.py
new file mode 100644
index 0000000..75861b0
--- /dev/null
+++ b/backend/app/api/routes_change_cases.py
@@ -0,0 +1,59 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+
+from app.core.auth import require_role
+from app.core.audit import write_audit_log
+from app.database import get_db
+from app.models import ChangeCase, Check, Report
+from app.schemas import ChangeCaseCreate, ChangeCaseRead, ChangeCaseUpdate
+
+router = APIRouter(prefix='/api/change-cases', tags=['change-cases'])
+VALID = {'draft', 'in_review', 'approved', 'closed'}
+TRANSITIONS = {
+ 'draft': {'in_review', 'closed'},
+ 'in_review': {'approved', 'draft', 'closed'},
+ 'approved': {'closed', 'in_review'},
+ 'closed': set(),
+}
+
+@router.get('')
+def list_change_cases(db: Session = Depends(get_db), _=Depends(require_role('viewer','operator','admin'))):
+ return db.query(ChangeCase).order_by(ChangeCase.updated_at.desc()).limit(100).all()
+
+@router.post('', response_model=ChangeCaseRead)
+def create_change_case(payload: ChangeCaseCreate, db: Session = Depends(get_db), user=Depends(require_role('operator','admin'))):
+ cc = ChangeCase(title=payload.title.strip(), description=payload.description, status='draft', created_by_user_id=user.id)
+ db.add(cc); db.commit(); db.refresh(cc)
+ write_audit_log(db, user_id=user.id, action='change_case_created', target_type='change_case', target_id=str(cc.id), details_json={'status': cc.status})
+ return cc
+
+@router.get('/{change_case_id}', response_model=ChangeCaseRead)
+def get_change_case(change_case_id: int, db: Session = Depends(get_db), _=Depends(require_role('viewer','operator','admin'))):
+ cc = db.query(ChangeCase).filter(ChangeCase.id == change_case_id).first()
+ if not cc: raise HTTPException(status_code=404, detail='Change Case not found')
+ return cc
+
+@router.patch('/{change_case_id}', response_model=ChangeCaseRead)
+def patch_change_case(change_case_id: int, payload: ChangeCaseUpdate, db: Session = Depends(get_db), user=Depends(require_role('operator','admin'))):
+ cc = db.query(ChangeCase).filter(ChangeCase.id == change_case_id).first()
+ if not cc: raise HTTPException(status_code=404, detail='Change Case not found')
+ old_status = cc.status
+ if payload.title is not None: cc.title = payload.title.strip()
+ if payload.description is not None: cc.description = payload.description
+ if payload.status is not None:
+ if payload.status not in VALID: raise HTTPException(status_code=400, detail='Invalid status')
+ if payload.status != cc.status and payload.status not in TRANSITIONS.get(cc.status, set()):
+ raise HTTPException(status_code=400, detail='Invalid status transition')
+ cc.status = payload.status
+ db.commit(); db.refresh(cc)
+ write_audit_log(db, user_id=user.id, action='change_case_updated', target_type='change_case', target_id=str(cc.id), details_json={})
+ if old_status != cc.status:
+ write_audit_log(db, user_id=user.id, action='change_case_status_changed', target_type='change_case', target_id=str(cc.id), details_json={'from': old_status, 'to': cc.status})
+ return cc
+
+@router.get('/{change_case_id}/reports')
+def list_change_case_reports(change_case_id: int, db: Session = Depends(get_db), _=Depends(require_role('viewer','operator','admin'))):
+ cc = db.query(ChangeCase).filter(ChangeCase.id == change_case_id).first()
+ if not cc: raise HTTPException(status_code=404, detail='Change Case not found')
+ rows = db.query(Report, Check).join(Check, Report.check_id == Check.id).filter(Check.change_case_id == change_case_id).all()
+ return [{'report_id': r.id, 'check_id': c.id, 'check_type': c.check_type, 'summary': c.summary, 'status': c.status, 'created_at': r.created_at.isoformat()} for r, c in rows]
diff --git a/backend/app/api/routes_checks.py b/backend/app/api/routes_checks.py
index 424abba..f700168 100644
--- a/backend/app/api/routes_checks.py
+++ b/backend/app/api/routes_checks.py
@@ -5,7 +5,7 @@
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.models import ChangeCase, Check, Report
from app.schemas import AsnCheckRequest, AsnRpkiBatchRequest, CheckResponse, PrefixCheckRequest, PreflightCheckRequest
from app.services.asn_checker import AsnChecker
from app.services.prefix_checker import PrefixChecker
@@ -20,7 +20,7 @@
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, user.id)
+ return _store_and_respond(db, "asn", payload.asn, None, result, user.id, payload.change_case_id)
except HTTPException:
raise
except Exception as exc:
@@ -31,7 +31,7 @@ def check_asn(payload: AsnCheckRequest, db: Session = Depends(get_db), user=Depe
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, user.id)
+ return _store_and_respond(db, "asn-rpki", payload.asn, None, result, user.id, payload.change_case_id)
except HTTPException:
raise
except Exception as exc:
@@ -42,7 +42,7 @@ def check_asn_rpki(payload: AsnRpkiBatchRequest, db: Session = Depends(get_db),
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, user.id)
+ return _store_and_respond(db, "prefix", payload.prefix, payload.origin_as, result, user.id, payload.change_case_id)
except HTTPException:
raise
except Exception as exc:
@@ -54,16 +54,21 @@ def check_prefix(payload: PrefixCheckRequest, db: Session = Depends(get_db), use
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, user.id)
+ return _store_and_respond(db, "preflight", payload.prefix, payload.planned_origin_as, result, user.id, payload.change_case_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, user_id: int | None = None) -> CheckResponse:
+def _store_and_respond(db: Session, ctype: str, resource: str, origin_as: str | None, result: dict, user_id: int | None = None, change_case_id: int | None = None) -> CheckResponse:
try:
- check = Check(check_type=ctype, input_resource=resource, origin_as=origin_as, status=result["status"], summary=result["summary"], created_by_user_id=user_id)
+ change_case = None
+ if change_case_id is not None:
+ change_case = db.query(ChangeCase).filter(ChangeCase.id == change_case_id).first()
+ if not change_case:
+ raise HTTPException(status_code=404, detail="Change Case not found")
+ check = Check(check_type=ctype, input_resource=resource, origin_as=origin_as, status=result["status"], summary=result["summary"], created_by_user_id=user_id, change_case_id=change_case_id)
db.add(check)
db.commit()
db.refresh(check)
@@ -74,6 +79,9 @@ def _store_and_respond(db: Session, ctype: str, resource: str, origin_as: str |
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})
+ if change_case_id is not None:
+ write_audit_log(db, user_id=user_id, action='check_attached_to_change_case', target_type='change_case', target_id=str(change_case_id), details_json={'check_id': check.id})
+ write_audit_log(db, user_id=user_id, action='report_attached_to_change_case', target_type='change_case', target_id=str(change_case_id), details_json={'report_id': report.id})
return CheckResponse(report_id=report.id, status=result["status"], summary=result["summary"], explanation=result.get("explanation"), risk=result.get("risk"), recommendations=result["recommendations"], input=result.get("input"), checks=result.get("checks"), details=result["details"], markdown=md, html=html)
except OperationalError as exc:
db.rollback()
diff --git a/backend/app/api/routes_system.py b/backend/app/api/routes_system.py
index b83e513..5dd6e8c 100644
--- a/backend/app/api/routes_system.py
+++ b/backend/app/api/routes_system.py
@@ -12,7 +12,7 @@
def system_info():
return {
'name': 'RouteForge',
- 'version': 'v0.6.6-beta',
+ 'version': 'v0.7.0-beta',
'demo_mode': settings.demo_mode,
'read_only': True,
'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'],
diff --git a/backend/app/core/system_status.py b/backend/app/core/system_status.py
index 2b23c69..4fb5d83 100644
--- a/backend/app/core/system_status.py
+++ b/backend/app/core/system_status.py
@@ -139,7 +139,7 @@ def build_system_status(engine: Engine | None) -> dict:
return {
"status": "ok",
"name": settings.app_name,
- "version": "v0.6.6-beta",
+ "version": "v0.7.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 fdfd85d..2379f29 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -6,6 +6,7 @@
from app.api.routes_audit import router as audit_router
from app.api.routes_auth import router as auth_router
+from app.api.routes_change_cases import router as change_cases_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
@@ -18,7 +19,7 @@
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
logger = logging.getLogger("routeforge")
-app = FastAPI(title="RouteForge", version="0.6.6")
+app = FastAPI(title="RouteForge", version="0.7.0")
app.add_middleware(
CORSMiddleware,
@@ -49,6 +50,7 @@ def startup() -> None:
app.include_router(health_router)
app.include_router(checks_router)
app.include_router(reports_router)
+app.include_router(change_cases_router)
app.include_router(system_router)
app.include_router(auth_router)
diff --git a/backend/app/models.py b/backend/app/models.py
index aebcb29..3409fc5 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -19,6 +19,18 @@ class User(Base):
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+class ChangeCase(Base):
+ __tablename__ = "change_cases"
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
+ title: Mapped[str] = mapped_column(String(200), nullable=False)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
+ created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+ checks: Mapped[list["Check"]] = relationship(back_populates="change_case")
+
+
class Check(Base):
__tablename__ = "checks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
@@ -29,6 +41,8 @@ class Check(Base):
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)
+ change_case_id: Mapped[int | None] = mapped_column(ForeignKey("change_cases.id"), nullable=True)
+ change_case: Mapped["ChangeCase | None"] = relationship(back_populates="checks")
report: Mapped["Report"] = relationship(back_populates="check", uselist=False)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 5f33973..62717e2 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -6,7 +6,29 @@
from app.core.normalize import normalize_asn, validate_prefix
+class ChangeCaseCreate(BaseModel):
+ title: str = Field(min_length=1, max_length=200)
+ description: str | None = None
+
+
+class ChangeCaseUpdate(BaseModel):
+ title: str | None = Field(default=None, min_length=1, max_length=200)
+ description: str | None = None
+ status: str | None = None
+
+
+class ChangeCaseRead(BaseModel):
+ id: int
+ title: str
+ description: str | None
+ status: str
+ created_by_user_id: int | None
+ created_at: datetime
+ updated_at: datetime
+
+
class AsnCheckRequest(BaseModel):
+ change_case_id: int | None = None
asn: str
@field_validator("asn")
@@ -19,6 +41,7 @@ def valid_asn(cls, v: str) -> str:
class AsnRpkiBatchRequest(BaseModel):
+ change_case_id: int | None = None
asn: str
limit: int = Field(default=25, ge=1, le=100)
@@ -30,6 +53,7 @@ def valid_asn(cls, v: str) -> str:
class PrefixCheckRequest(BaseModel):
+ change_case_id: int | None = None
prefix: str
origin_as: str | None = None
@@ -49,6 +73,7 @@ def valid_origin(cls, v: str | None) -> str | None:
class PreflightCheckRequest(BaseModel):
+ change_case_id: int | None = None
prefix: str
planned_origin_as: str
diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py
index ad9d957..7b206ef 100644
--- a/backend/tests/test_api_smoke.py
+++ b/backend/tests/test_api_smoke.py
@@ -177,7 +177,7 @@ def test_system_status_endpoint() -> None:
response = client.get('/api/system/status')
assert response.status_code == 200
payload = response.json()
- assert payload.get('version') == 'v0.6.6-beta'
+ assert payload.get('version') == 'v0.7.0-beta'
assert payload.get('read_only') is True
assert payload.get('database', {}).get('status')
assert payload.get('ripestat', {}).get('cache_ttl_seconds') is not None
@@ -369,3 +369,36 @@ def test_cookie_name_and_logout_cookie_settings() -> None:
assert out.status_code == 200
logout_cookie = out.headers.get('set-cookie', '')
assert 'rf_cookie_test=' in logout_cookie
+
+def test_change_case_crud_and_roles() -> None:
+ client = _client()
+ _setup_and_login(client)
+ create = client.post('/api/change-cases', json={'title': 'Case 1', 'description': 'desc'})
+ assert create.status_code == 200
+ cid = create.json()['id']
+ assert client.get('/api/change-cases').status_code == 200
+ patch = client.patch(f'/api/change-cases/{cid}', json={'status': 'in_review'})
+ assert patch.status_code == 200
+
+ client.post('/api/users', json={'username': 'viewer2', 'email': 'viewer2@example.org', 'password': 'ViewerPass123!', 'role': 'viewer'})
+ client.post('/api/auth/logout')
+ assert client.post('/api/auth/login', json={'username': 'viewer2', 'password': 'ViewerPass123!'}).status_code == 200
+ assert client.get('/api/change-cases').status_code == 200
+ assert client.post('/api/change-cases', json={'title': 'Nope'}).status_code == 403
+
+
+def test_change_case_invalid_status_and_check_attach_and_audit() -> None:
+ client = _client()
+ _setup_and_login(client)
+ cid = client.post('/api/change-cases', json={'title': 'Case 2', 'description': ''}).json()['id']
+ bad = client.patch(f'/api/change-cases/{cid}', json={'status': 'invalid'})
+ assert bad.status_code == 400
+ check = client.post('/api/check/asn', json={'asn': 'AS3320', 'change_case_id': cid})
+ assert check.status_code == 200
+ reports = client.get(f'/api/change-cases/{cid}/reports')
+ assert reports.status_code == 200
+ assert isinstance(reports.json(), list)
+ audit = client.get('/api/audit-log')
+ actions = [i.get('action') for i in audit.json().get('items', [])]
+ assert 'check_attached_to_change_case' in actions
+ assert 'report_attached_to_change_case' in actions
diff --git a/frontend/package.json b/frontend/package.json
index 053bbbc..3c354dc 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "routeforge-frontend",
- "version": "0.6.6",
+ "version": "0.7.0",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 5621ba5..90042b6 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -10,8 +10,9 @@ import { SetupView } from './components/SetupView'
import { StatusBadge } from './components/StatusBadge'
import type { ReportListItem, SystemInfo, SystemStatus, User, UserRole } from './types'
import { UsersView } from './components/UsersView'
+import { ChangeCasesView } from './components/ChangeCasesView'
-type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'system' | 'users' | 'audit' | 'about'
+type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'change-cases' | 'system' | 'users' | 'audit' | 'about'
type AuthMode = 'loading' | 'setup' | 'login' | 'app' | 'error'
export default function App() {
@@ -70,8 +71,8 @@ export default function App() {
if (authMode === 'login') 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.
| {r.summary} |
Version: v0.6.6-beta
Version: v0.7.0-beta
| Title | Status | Owner | Created | Updated |
|---|---|---|---|---|
| {i.title} | {i.status} | {i.created_by_user_id ?? '—'} | {new Date(i.created_at).toLocaleString()} | {new Date(i.updated_at).toLocaleString()} |
{selected.description || '—'}