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
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<img src="frontend/public/routeforge.png" alt="RouteForge Logo" width="420">
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-v0.6.4--beta-blue" alt="Version">
<img src="https://img.shields.io/badge/version-v0.6.5--beta-blue" alt="Version">
<img src="https://img.shields.io/badge/license-AGPL--3.0--or--later-orange" alt="License">
<img src="https://img.shields.io/badge/status-beta-yellow" alt="Status">
<img src="https://img.shields.io/badge/selfhosted-ready-success" alt="Selfhosted">
Expand Down 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.4-beta**.
RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.6.5-beta**.

## Quickstart with Docker Compose

Expand Down Expand Up @@ -178,7 +178,9 @@ RouteForge is read-only by design:
- RIPEstat payloads can vary over time.
- No local RPKI validator yet.
- No full BGP monitoring replacement.
- No user management yet.
- No OAuth/SSO yet.
- No LDAP yet.
- No email password reset flow yet.


## Selfhosting
Expand Down Expand Up @@ -262,3 +264,13 @@ For production polish and selfhosting hardening guidance, see:
- `docs/operations/release-checklist.md`

In the standard Docker setup, API calls are same-origin via frontend nginx (`/api` proxy). CORS is primarily needed for split frontend/backend deployments.


## User Management

- Initial admin setup is required on first start.
- Login/Logout are session-cookie based.
- Roles: `admin`, `operator`, `viewer`.
- User management is **admin-only**.
- Viewers cannot execute checks.
- Keep `SECRET_KEY` stable; changing it invalidates existing sessions.
24 changes: 24 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# Release Notes

## v0.6.5-beta

**Auth UX & Admin Console Polish**

### Highlights

- Logged-in user and role are clearly visible in the UI.
- Added visible logout flow.
- Added admin-only user management UI.
- Added role-aware navigation for admin/operator/viewer.
- Improved permission and session-expired messages.
- Dashboard now explains current user capabilities.
- User management API responses avoid password hash exposure.
- Audit Log UI/API may be included if implemented.

### Known limitations

- No OAuth/SSO yet.
- No LDAP yet.
- No email password reset flow yet.
- Audit log UI may still be limited if not implemented in this sprint.

---

## v0.6.4-beta

**Alembic Logging Config Hotfix**
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.4-beta',
'version': 'v0.6.5-beta',
'demo_mode': settings.demo_mode,
'read_only': True,
'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'],
Expand Down
11 changes: 7 additions & 4 deletions backend/app/api/routes_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class UserPatch(BaseModel):
@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]
return [{"id":u.id,"username":u.username,"email":u.email,"role":u.role,"is_active":u.is_active,"created_at":u.created_at.isoformat(),"updated_at":u.updated_at.isoformat(),"last_login_at":u.last_login_at.isoformat() if u.last_login_at else None} for u in users]

@router.post('')
def create_user(payload: UserCreate, _: User = Depends(require_admin), db: Session = Depends(get_db)):
Expand All @@ -34,18 +34,21 @@ def create_user(payload: UserCreate, _: User = Depends(require_admin), db: Sessi
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}
return {"id":user.id,"username":user.username,"email":user.email,"role":user.role,"is_active":user.is_active,"created_at":user.created_at.isoformat(),"updated_at":user.updated_at.isoformat(),"last_login_at":user.last_login_at.isoformat() if user.last_login_at else None}

@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.role is not None:
if payload.role not in {'admin','operator','viewer'}:
raise HTTPException(status_code=400, detail='Invalid role')
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}
return {"id":user.id,"username":user.username,"email":user.email,"role":user.role,"is_active":user.is_active,"created_at":user.created_at.isoformat(),"updated_at":user.updated_at.isoformat(),"last_login_at":user.last_login_at.isoformat() if user.last_login_at else None}
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.4-beta",
"version": "v0.6.5-beta",
"read_only": True,
"mode": "demo" if settings.demo_mode else "live",
"demo_mode": settings.demo_mode,
Expand Down
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
logger = logging.getLogger("routeforge")

app = FastAPI(title="RouteForge", version="0.6.4")
app = FastAPI(title="RouteForge", version="0.6.5")

app.add_middleware(
CORSMiddleware,
Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "routeforge-backend"
version = "0.6.4"
version = "0.6.5"
description = "RouteForge backend"
license = "AGPL-3.0-or-later"
requires-python = ">=3.12"
Expand Down
44 changes: 43 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.4-beta'
assert payload.get('version') == 'v0.6.5-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 @@ -278,3 +278,45 @@ def rollback(self):
except Exception as exc:
assert getattr(exc, "status_code", None) == 503
assert "Database schema is not up to date" in str(getattr(exc, "detail", ""))

def test_users_endpoint_admin_only_and_no_password_hash() -> None:
client = _client()
_setup_and_login(client)
created = client.post('/api/users', json={'username': 'u1', 'email': 'u1@example.org', 'password': 'UserPass123!', 'role': 'viewer'})
assert created.status_code == 200
assert 'password_hash' not in created.json()
resp = client.get('/api/users')
assert resp.status_code == 200
for row in resp.json():
assert 'password_hash' not in row
assert 'updated_at' in row
assert 'last_login_at' in row


def test_users_endpoint_forbidden_for_operator_and_viewer() -> None:
client = _client()
_setup_and_login(client)
assert client.post('/api/users', json={'username': 'op2', 'email': 'op2@example.org', 'password': 'OperatorPass123!', 'role': 'operator'}).status_code == 200
assert client.post('/api/users', json={'username': 'vw2', 'email': 'vw2@example.org', 'password': 'ViewerPass123!', 'role': 'viewer'}).status_code == 200

client.post('/api/auth/logout')
assert client.post('/api/auth/login', json={'username': 'op2', 'password': 'OperatorPass123!'}).status_code == 200
assert client.get('/api/users').status_code == 403

client.post('/api/auth/logout')
assert client.post('/api/auth/login', json={'username': 'vw2', 'password': 'ViewerPass123!'}).status_code == 200
assert client.get('/api/users').status_code == 403


def test_inactive_user_cannot_login() -> None:
client = _client()
_setup_and_login(client)
create = client.post('/api/users', json={'username': 'inactive1', 'email': 'inactive@example.org', 'password': 'InactivePass123!', 'role': 'viewer'})
assert create.status_code == 200
uid = create.json()['id']
patch = client.patch(f'/api/users/{uid}', json={'is_active': False})
assert patch.status_code == 200

client.post('/api/auth/logout')
login = client.post('/api/auth/login', json={'username': 'inactive1', 'password': 'InactivePass123!'})
assert login.status_code == 401
7 changes: 5 additions & 2 deletions docs/operations/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,8 @@ The backend entrypoint ensures `/app/data` is writable for the non-root runtime

## Current limitations

- no authentication yet
- no multi-user support yet
- Role model: admin/operator/viewer
- Admin-only user management
- Inactive users cannot log in
- Password reset is admin-driven (set new password in user management)
- No external auth/SSO in this version
17 changes: 17 additions & 0 deletions docs/operations/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,20 @@ Update auf `v0.6.4-beta` oder neuer, dann:
```bash
docker compose exec backend alembic upgrade head
```


## Ich sehe keine Check-Menüpunkte

Rolle prüfen: `viewer` sieht nur Dashboard/Reports/About.

## 403 bei Checks

User ist `viewer` oder inaktiv. Rolle und `is_active` im Admin User Management prüfen.

## Login geht nicht

Prüfen: User aktiv? Passwort korrekt? Wurde `SECRET_KEY` geändert?

## Nach SECRET_KEY Änderung

Alle Sessions sind ungültig. Bitte neu einloggen.
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.4",
"version": "0.6.5",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
Expand Down
40 changes: 27 additions & 13 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { PrefixCheckForm } from './components/PrefixCheckForm'
import { PreflightCheckForm } from './components/PreflightCheckForm'
import { SetupView } from './components/SetupView'
import { StatusBadge } from './components/StatusBadge'
import type { ReportListItem, SystemInfo, SystemStatus } from './types'
import type { ReportListItem, SystemInfo, SystemStatus, User, UserRole } from './types'
import { UsersView } from './components/UsersView'

type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'system' | 'about'
type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'system' | 'users' | 'about'
type AuthMode = 'loading' | 'setup' | 'login' | 'app' | 'error'

export default function App() {
Expand All @@ -20,7 +21,7 @@ export default function App() {
const [system, setSystem] = useState<SystemInfo | null>(null)
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null)
const [systemStatusError, setSystemStatusError] = useState('')
const [currentUser, setCurrentUser] = useState<{ username: string; role: string } | null>(null)
const [currentUser, setCurrentUser] = useState<User | null>(null)

const loadAppData = () => {
getReports().then(setReports).catch(() => setReports([]))
Expand All @@ -39,7 +40,7 @@ export default function App() {
if (setup.setup_required) { setAuthMode('setup'); setCurrentUser(null); return }
try {
const me = await getMe()
setCurrentUser({ username: me.user.username, role: me.user.role })
setCurrentUser(me.user)
setAuthMode('app'); loadAppData()
} catch (err: unknown) {
if (err instanceof ApiError && err.status === 401) { setAuthMode('login'); setCurrentUser(null); return }
Expand All @@ -49,7 +50,10 @@ export default function App() {
}

useEffect(() => { bootstrapAuth() }, [])
const handleLogout = async () => { await logout(); setCurrentUser(null); setAuthMode('login') }
const handleLogout = async () => {
try { await logout() } catch { setAuthError('Logout request failed, but local session was cleared.') }
setCurrentUser(null); setAuthMode('login'); setActive('dashboard'); setReports([]); setSystemStatus(null)
}

const onSetupSubmit = async (payload: { username: string; email?: string; password: string; password_confirm: string }) => {
setAuthError('')
Expand All @@ -65,19 +69,29 @@ export default function App() {
if (authMode === 'login') return <LoginView onSubmit={onLoginSubmit} error={authError} />
if (authMode === 'error') return <div className='p-8 text-center text-rose-700'>{authError}</div>

const systemLine = system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.6.4-beta · read-only preflight checks'
const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', reports: 'Reports', system: 'System Status', about: 'About RouteForge' }[active]
const systemLine = system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.6.5-beta · read-only preflight checks'
const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', reports: 'Reports', system: 'System Status', users: 'User Management', about: 'About RouteForge' }[active]
const proxyStatus = systemStatusError ? 'ERROR' : 'OK'
const migrationStatus = systemStatus?.database?.migration_status || 'unknown'
const migrationsBlocked = migrationStatus === 'behind' || migrationStatus === 'error'

const role = (currentUser?.role || 'viewer') as UserRole
const canAccess = (view: NavKey) => {
if (role === 'admin') return true
if (role === 'operator') return view !== 'users'
return ['dashboard', 'reports', 'about'].includes(view)
}
const allowedActions = role === 'admin' ? 'You can run checks, manage users, view reports and system status.' : role === 'operator' ? 'You can run checks and view reports.' : 'You can view reports.'

return <Layout active={active} onNav={setActive} systemLine={systemLine} title={title} demoMode={Boolean(system?.demo_mode)} currentUser={currentUser} onLogout={handleLogout}>
{active === 'dashboard' && <section className='space-y-4'><article className='rf-card p-6'><h1 className='text-2xl font-bold'>RouteForge v0.6.4-beta</h1><p className='mt-2 text-slate-600'>Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.</p></article>{migrationsBlocked && <article className='rf-card border border-amber-300 bg-amber-50 p-4 text-amber-900'>Database migrations are required before using RouteForge.</article>}</section>}
{active === 'asn' && <AsnCheckForm />}
{active === 'prefix' && <PrefixCheckForm />}
{active === 'preflight' && <PreflightCheckForm />}
{active === 'dashboard' && <section className='space-y-4'><article className='rf-card p-6'><h1 className='text-2xl font-bold'>RouteForge v0.6.5-beta</h1><p className='mt-2 text-slate-600'>Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.</p></article><article className='rf-card p-4 text-sm'><div><b>Logged in as:</b> {currentUser?.username}</div><div><b>Role:</b> {currentUser?.role}</div><div><b>Allowed actions:</b> {allowedActions}</div></article>{migrationsBlocked && <article className='rf-card border border-amber-300 bg-amber-50 p-4 text-amber-900'>Database migrations are required before using RouteForge.</article>}</section>}
{!canAccess(active) && <article className='rf-card p-4 text-amber-800 bg-amber-50 border border-amber-200'>You do not have permission to access this section.</article>}
{active === 'asn' && canAccess('asn') && <AsnCheckForm />}
{active === 'prefix' && canAccess('prefix') && <PrefixCheckForm />}
{active === 'preflight' && canAccess('preflight') && <PreflightCheckForm />}
{active === 'reports' && <section className='rf-card p-4'><h2 className='mb-3 text-xl font-semibold'>Reports</h2>{reports.length===0 ? <div className='rounded-xl border border-dashed border-slate-300 p-6 text-sm text-slate-500'>Noch keine Reports vorhanden.</div> : <div className='overflow-x-auto'><table className='w-full text-sm'><tbody>{reports.map(r=><tr key={r.report_id}><td>{r.summary}</td><td><button className='rf-btn-secondary' onClick={async ()=>navigator.clipboard?.writeText(await getReportSummary(r.report_id))}>Copy Summary</button><button className='rf-btn-secondary' onClick={async ()=>{const t=await getReportMarkdown(r.report_id);const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([t],{type:'text/markdown'}));a.download=`routeforge-report-${r.report_id}.md`;a.click()}}>Download Markdown</button><button className='rf-btn-secondary' onClick={async ()=>{const t=await getReportHtml(r.report_id);const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([t],{type:'text/html'}));a.download=`routeforge-report-${r.report_id}.html`;a.click()}}>Download HTML</button></td></tr>)}</tbody></table></div>}</section>}
{active === 'system' && <section className='space-y-3'>{systemStatusError && <article className='rf-card p-4 text-rose-700'>{systemStatusError}</article>}{migrationsBlocked && <article className='rf-card border border-amber-300 bg-amber-50 p-4 text-amber-900'>Database migrations are required before using RouteForge.</article>}{systemStatus && <article className='rf-card p-4 grid gap-2 md:grid-cols-2 text-sm'><div>Version: <b>{systemStatus.version}</b></div><div>Mode: <b>{systemStatus.mode}</b></div><div>API Proxy: <b>{proxyStatus}</b></div><div>Migration Status: <b>{migrationStatus}</b> <StatusBadge status={migrationStatus === 'up_to_date' ? 'OK' : migrationStatus === 'behind' ? 'WARNING' : migrationStatus === 'error' ? 'CRITICAL' : 'UNKNOWN'} /></div></article>}</section>}
{active === 'about' && <section className='rf-card p-5 space-y-2 text-sm text-slate-700'><p><b>Version:</b> v0.6.4-beta</p></section>}
{active === 'system' && canAccess('system') && <section className='space-y-3'>{systemStatusError && <article className='rf-card p-4 text-rose-700'>{systemStatusError}</article>}{migrationsBlocked && <article className='rf-card border border-amber-300 bg-amber-50 p-4 text-amber-900'>Database migrations are required before using RouteForge.</article>}{systemStatus && <article className='rf-card p-4 grid gap-2 md:grid-cols-2 text-sm'><div>Version: <b>{systemStatus.version}</b></div><div>Mode: <b>{systemStatus.mode}</b></div><div>API Proxy: <b>{proxyStatus}</b></div><div>Migration Status: <b>{migrationStatus}</b> <StatusBadge status={migrationStatus === 'up_to_date' ? 'OK' : migrationStatus === 'behind' ? 'WARNING' : migrationStatus === 'error' ? 'CRITICAL' : 'UNKNOWN'} /></div></article>}</section>}
{active === 'users' && canAccess('users') && <UsersView />}
{active === 'about' && <section className='rf-card p-5 space-y-2 text-sm text-slate-700'><p><b>Version:</b> v0.6.5-beta</p></section>}
</Layout>
}
Loading
Loading