RouteForge v0.6.4-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 66e95e3..e247126 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
- 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. Version: v0.6.4-beta Version: v0.6.5-beta
+
@@ -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
@@ -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
@@ -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.
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 6957f78..7bbc10b 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -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**
diff --git a/backend/app/api/routes_system.py b/backend/app/api/routes_system.py
index a6f8eb5..e397853 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.4-beta',
+ 'version': 'v0.6.5-beta',
'demo_mode': settings.demo_mode,
'read_only': True,
'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'],
diff --git a/backend/app/api/routes_users.py b/backend/app/api/routes_users.py
index 0d7aba5..3ef1927 100644
--- a/backend/app/api/routes_users.py
+++ b/backend/app/api/routes_users.py
@@ -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)):
@@ -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}
diff --git a/backend/app/core/system_status.py b/backend/app/core/system_status.py
index e885b7c..3ab8f7c 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.4-beta",
+ "version": "v0.6.5-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 ac2a577..39e7a72 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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,
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index d54f240..f9a8e1f 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -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"
diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py
index e1876a3..b3f13be 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.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
@@ -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
diff --git a/docs/operations/security.md b/docs/operations/security.md
index 8a8bc8b..ad25db4 100644
--- a/docs/operations/security.md
+++ b/docs/operations/security.md
@@ -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
diff --git a/docs/operations/troubleshooting.md b/docs/operations/troubleshooting.md
index 6cd3136..b993ad3 100644
--- a/docs/operations/troubleshooting.md
+++ b/docs/operations/troubleshooting.md
@@ -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.
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index c44e3eb..1e71bec 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "routeforge-frontend",
- "version": "0.6.4",
+ "version": "0.6.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "routeforge-frontend",
- "version": "0.6.4",
+ "version": "0.6.5",
"license": "AGPL-3.0-or-later",
"dependencies": {
"react": "^18.3.1",
diff --git a/frontend/package.json b/frontend/package.json
index dbfd135..324d1af 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8cdcc2d..34b159a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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() {
@@ -20,7 +21,7 @@ export default function App() {
const [system, setSystem] = useState
RouteForge v0.6.4-beta
RouteForge v0.6.5-beta
Reports
{reports.length===0 ? {reports.map(r=>
)}{r.summary} User Management
+ {error && {users.map(u => User Role Status Actions )}{u.username} {u.role} {u.is_active ? 'active' : 'inactive'}