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
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.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

Expand Down
9 changes: 8 additions & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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**

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
31 changes: 31 additions & 0 deletions backend/alembic/versions/0003_change_cases.py
Original file line number Diff line number Diff line change
@@ -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')
59 changes: 59 additions & 0 deletions backend/app/api/routes_change_cases.py
Original file line number Diff line number Diff line change
@@ -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]
22 changes: 15 additions & 7 deletions backend/app/api/routes_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/routes_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
def system_info():
return {
'name': 'RouteForge',
'version': 'v0.6.6-beta',
'version': 'v0.7.0-beta',
'demo_mode': settings.demo_mode,
'read_only': True,
'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'],
Expand Down
2 changes: 1 addition & 1 deletion backend/app/core/system_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def build_system_status(engine: Engine | None) -> dict:
return {
"status": "ok",
"name": settings.app_name,
"version": "v0.6.6-beta",
"version": "v0.7.0-beta",
"read_only": True,
"mode": "demo" if settings.demo_mode else "live",
"demo_mode": settings.demo_mode,
Expand Down
4 changes: 3 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)


Expand Down
25 changes: 25 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)

Expand All @@ -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

Expand All @@ -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

Expand Down
35 changes: 34 additions & 1 deletion backend/tests/test_api_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading