fix(auth): honor AUTH_ENABLED=false in admin-gated app-config routes (#6029)

* fix(auth): honor AUTH_ENABLED=false in admin-gated app-config routes

With auth explicitly disabled (reverse-proxy deployments) there is no
session user, so the inline admin checks in auth_routes.py returned 403
unconditionally and settings changes were silently lost. The central
require_admin in core/middleware.py already allows this case; the inline
checks did not.

- add _require_admin_user helper mirroring require_admin semantics and
  use it in the app-config routes (settings, features, integrations)
- return unscrubbed settings on GET /settings when auth is disabled
  (the caller is the operator)
- settings.js: check res.ok in saveDefault so a failed save no longer
  reports 'Saved'
- add regression tests

User-management routes keep requiring a real admin identity; they act on
the user store, which is not meaningful with auth disabled.

Fixes #6028

* fix(settings): verify response.ok on save and expand auth regression tests

- Ensure all settings save handlers in settings.js check response.ok before displaying success notifications, preventing false 'Saved' messages on server rejection.
- Add DOM and JS unit tests verifying response.ok checks and error handling across settings handlers.
- Expand tests/test_admin_routes_auth_disabled.py with comprehensive authorization test coverage for AUTH_ENABLED=false and AUTH_ENABLED=true across settings, features, integrations, secret scrubbing, and user management.

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
Co-authored-by: PR #6029 Reviewer <reviewer@odysseus-review.local>
This commit is contained in:
madonix
2026-10-14 09:47:57 +01:00
committed by GitHub
co-authored by Alexandre Teixeira PR #6029 Reviewer
parent 0c56527d12
commit 353ea00736
4 changed files with 477 additions and 25 deletions
+25 -25
View File
@@ -15,6 +15,7 @@ from pathlib import Path
from core.atomic_io import atomic_write_json, atomic_write_text
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
from src.auth_helpers import _auth_disabled
from src.rate_limiter import RateLimiter
from src.settings_scrub import scrub_settings
from src.settings import (
@@ -129,6 +130,21 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
token = request.cookies.get(SESSION_COOKIE)
return auth_manager.get_username_for_token(token)
def _require_admin_user(request: Request) -> Optional[str]:
"""Resolve the current user, enforcing admin access.
Honors AUTH_ENABLED=false the same way core.middleware.require_admin
does: when the operator has explicitly disabled auth (e.g. behind a
reverse proxy that handles login), there is no session user, and
admin-gated app configuration must stay reachable.
"""
user = _get_current_user(request)
if _auth_disabled():
return user
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
return user
@router.post("/setup")
async def first_run_setup(body: SetupRequest, request: Request):
"""Create initial admin account. Only works if no accounts exist."""
@@ -718,9 +734,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
@router.post("/features")
async def set_features(request: Request):
"""Admin only: update feature toggles."""
user = _get_current_user(request)
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
_require_admin_user(request)
body = await request.json()
current = _load_features()
for key in current:
@@ -738,16 +752,14 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
for keybinds + TTS prefs, so it stays callable without admin."""
user = _get_current_user(request)
settings = without_retired_settings(_load_settings())
if user and auth_manager.is_admin(user):
if _auth_disabled() or (user and auth_manager.is_admin(user)):
return settings
return scrub_settings(settings)
@router.post("/settings")
async def set_settings(request: Request):
"""Admin only: update app settings."""
user = _get_current_user(request)
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
_require_admin_user(request)
body = await request.json()
current = _load_settings()
# Per-key validation for numeric settings: coerce to int and clamp to a
@@ -780,9 +792,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
file: UploadFile = File(...),
):
"""Infer the general prose style from one user-supplied document."""
user = _get_current_user(request)
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
user = _require_admin_user(request)
filename = Path(file.filename or "sample.txt").name
suffix = Path(filename).suffix.lower()
allowed = {
@@ -861,9 +871,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
@router.get("/integrations")
async def list_integrations_route(request: Request):
"""List all integrations (admin only, keys masked)."""
user = _get_current_user(request)
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
_require_admin_user(request)
items = load_integrations()
# Mask API keys for frontend display
safe = [mask_integration_secret(item) for item in items]
@@ -877,9 +885,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
@router.post("/integrations")
async def create_integration(request: Request):
"""Create a new integration (admin only)."""
user = _get_current_user(request)
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
_require_admin_user(request)
body = await request.json()
item = add_integration(body)
return {"ok": True, "integration": mask_integration_secret(item)}
@@ -887,9 +893,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
@router.put("/integrations/{integration_id}")
async def update_integration_route(integration_id: str, request: Request):
"""Update an existing integration (admin only)."""
user = _get_current_user(request)
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
_require_admin_user(request)
body = await request.json()
item = update_integration(integration_id, body)
if not item:
@@ -899,9 +903,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
@router.delete("/integrations/{integration_id}")
async def delete_integration_route(integration_id: str, request: Request):
"""Delete an integration (admin only)."""
user = _get_current_user(request)
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
_require_admin_user(request)
ok = delete_integration(integration_id)
if not ok:
raise HTTPException(404, "Integration not found")
@@ -910,9 +912,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
@router.post("/integrations/{integration_id}/test")
async def test_integration_route(integration_id: str, request: Request):
"""Test connectivity to an integration (admin only)."""
user = _get_current_user(request)
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
_require_admin_user(request)
integ = get_integration(integration_id)
if not integ:
raise HTTPException(404, "Integration not found")