fix(settings): enforce authoritative global image-gen policy and non-admin preference persistence

- Enforce authoritative administrator global image_gen_enabled disable in get_user_setting, do_generate_image, and image_editing_provider_available, preventing non-admin user preference overrides while preserving user-disabled opt-outs and can_generate_images privilege checks across direct, gallery, and editor paths.
- Filter non-admin preference persistence in saveImagePreferences to user preference keys only (image_model, image_edit_model, image_quality), preventing global policy toggles from leaking into user preferences.
- Return accurate 200 OK Response from saveImagePreferences after fallback persistence succeeds so settings UI displays 'Saved' instead of false 403 error, while retaining visibility of real errors.
- Load per-user preferences via readImagePreferences in initImageSettings so non-admin selections persist and display correctly after page reload.
- Clear per-user model preferences on successful admin save (POST /api/auth/settings) via PUT /api/prefs/{key} with value: null to prevent stale per-user preferences from shadowing updated global settings.
- Support clearing keys in PUT /api/prefs/{key} when value is null.
- Add comprehensive Python regression tests for global-disabled/user-enabled, global-enabled/user-disabled, and authorized generation, plus Node and browser regression tests for non-admin persistence, toggle isolation, reload, and admin clearing.
This commit is contained in:
Alexandre Teixeira
2026-10-31 20:35:44 +00:00
parent 1cb597ce12
commit 1888839e06
8 changed files with 467 additions and 11 deletions
@@ -293,3 +293,208 @@ def test_is_background_removal_intent_grammar_and_dos_safety():
assert not _is_background_removal_intent(evil_invalid)
elapsed_invalid = time.perf_counter() - start
assert elapsed_invalid < 0.1, f"Invalid intent parsing took too long: {elapsed_invalid:.4f}s"
def test_image_generation_global_disabled_user_enabled_rejected(tmp_path, monkeypatch):
import asyncio
from fastapi import HTTPException
from routes.gallery import gallery_routes as gallery
from routes import editor_agent_routes, prefs_routes
from src import ai_interaction, settings
prefs_file = str(tmp_path / "user_prefs.json")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
# Admin globally disables image generation
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": False, "image_model": "gpt-image-1"})
# User 'alice' has image_gen_enabled = True in her preferences
prefs_routes._save_for_user("alice", {"image_gen_enabled": True, "image_model": "alice-model"})
# 1. get_user_setting must be False despite user preference
assert settings.get_user_setting("image_gen_enabled", owner="alice", default=True) is False
# 2. image_editing_provider_available must be False
assert ai_interaction.image_editing_provider_available(owner="alice") is False
# 3. Direct generation must be rejected with admin disabled message
direct_res = asyncio.run(ai_interaction.do_generate_image(
json.dumps({"prompt": "a dog"}), owner="alice"
))
assert "error" in direct_res
assert "disabled by the administrator" in direct_res["error"]
# 4. Gallery route must reject with 503
app = FastAPI()
app.include_router(gallery.setup_gallery_routes())
monkeypatch.setattr(gallery, "require_privilege", lambda request, priv: "alice")
with TestClient(app) as client:
gal_res = client.post("/api/image/generate", json={"prompt": "a dog"})
assert gal_res.status_code == 503
assert "switched off" in gal_res.json()["detail"].lower()
# 5. Editor agent route must be blocked
app_editor = FastAPI()
app_editor.include_router(editor_agent_routes.setup_editor_agent_routes())
monkeypatch.setattr(editor_agent_routes, "_user", lambda request: "alice")
routes.set_editor_planner(lambda messages, owner: json.dumps({
"reply": "Gen", "steps": [{"op": "generate_image", "params": {"prompt": "a dog"}}]
}))
try:
with TestClient(app_editor) as client:
ed_res = client.post("/api/editor/agent", json={"prompt": "create a dog"})
assert ed_res.status_code == 200
data = ed_res.json()
assert data.get("blocked") is True
assert data.get("needs_provider") is True
finally:
routes.set_editor_planner(None)
def test_image_generation_global_enabled_user_disabled_rejected(tmp_path, monkeypatch):
import asyncio
from routes.gallery import gallery_routes as gallery
from routes import editor_agent_routes, prefs_routes
from src import ai_interaction, settings
prefs_file = str(tmp_path / "user_prefs.json")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
# Admin globally enables image generation
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": True, "image_model": "gpt-image-1"})
# User 'bob' has image_gen_enabled = False in his preferences
prefs_routes._save_for_user("bob", {"image_gen_enabled": False, "image_model": "bob-model"})
# 1. get_user_setting must be False per user preference
assert settings.get_user_setting("image_gen_enabled", owner="bob", default=True) is False
# 2. image_editing_provider_available must be False for bob
assert ai_interaction.image_editing_provider_available(owner="bob") is False
# 3. Direct generation must be rejected with user preference message
direct_res = asyncio.run(ai_interaction.do_generate_image(
json.dumps({"prompt": "a cat"}), owner="bob"
))
assert "error" in direct_res
assert "disabled by user preference" in direct_res["error"]
# 4. Gallery route must reject with 503
app = FastAPI()
app.include_router(gallery.setup_gallery_routes())
monkeypatch.setattr(gallery, "require_privilege", lambda request, priv: "bob")
with TestClient(app) as client:
gal_res = client.post("/api/image/generate", json={"prompt": "a cat"})
assert gal_res.status_code == 503
# 5. Editor agent route must be blocked for bob
app_editor = FastAPI()
app_editor.include_router(editor_agent_routes.setup_editor_agent_routes())
monkeypatch.setattr(editor_agent_routes, "_user", lambda request: "bob")
routes.set_editor_planner(lambda messages, owner: json.dumps({
"reply": "Gen", "steps": [{"op": "generate_image", "params": {"prompt": "a cat"}}]
}))
try:
with TestClient(app_editor) as client:
ed_res = client.post("/api/editor/agent", json={"prompt": "create a cat"})
assert ed_res.status_code == 200
data = ed_res.json()
assert data.get("blocked") is True
assert data.get("needs_provider") is True
finally:
routes.set_editor_planner(None)
def test_image_generation_authorized_generation_and_privilege_checks(tmp_path, monkeypatch):
import asyncio
from fastapi import HTTPException
from routes.gallery import gallery_routes as gallery
from routes import editor_agent_routes, prefs_routes
from src import ai_interaction, settings
prefs_file = str(tmp_path / "user_prefs.json")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
# Admin globally enables image generation
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": True, "image_model": "gpt-image-1"})
# User 'charlie' has image generation enabled (no disabling pref)
prefs_routes._save_for_user("charlie", {"image_model": "charlie-gen"})
assert settings.get_user_setting("image_gen_enabled", owner="charlie", default=True) is True
# Check privilege enforcement: user lacking can_generate_images is forbidden
def require_privilege_mock(request, privilege):
if privilege == "can_generate_images":
raise HTTPException(403, "Permission denied")
return "charlie"
monkeypatch.setattr("src.auth_helpers.require_privilege", require_privilege_mock)
monkeypatch.setattr(gallery, "require_privilege", require_privilege_mock)
# Gallery checks privilege and returns 403
app = FastAPI()
app.include_router(gallery.setup_gallery_routes())
with TestClient(app) as client:
gal_res = client.post("/api/image/generate", json={"prompt": "a bird"})
assert gal_res.status_code == 403
assert "Permission denied" in gal_res.text
# Editor checks privilege on AI content and raises 403
app_editor = FastAPI()
app_editor.include_router(editor_agent_routes.setup_editor_agent_routes())
monkeypatch.setattr(editor_agent_routes, "_user", lambda request: "charlie")
monkeypatch.setattr(editor_agent_routes, "_image_provider_available", lambda owner: True)
routes.set_editor_planner(lambda messages, owner: json.dumps({
"reply": "Gen", "steps": [{"op": "generate_image", "params": {"prompt": "a bird"}}]
}))
try:
with TestClient(app_editor) as client:
ed_res = client.post("/api/editor/agent", json={"prompt": "create a bird"})
assert ed_res.status_code == 403
finally:
routes.set_editor_planner(None)
# Now grant privilege: generation proceeds
monkeypatch.setattr("src.auth_helpers.require_privilege", lambda request, priv: "charlie")
monkeypatch.setattr(gallery, "require_privilege", lambda request, priv: "charlie")
seen = []
async def fake_do_generate(content, **kwargs):
seen.append(kwargs)
return {"image_url": "data:image/png;base64,dGVzdA=="}
monkeypatch.setattr(ai_interaction, "do_generate_image", fake_do_generate)
with TestClient(app) as client:
gal_ok = client.post("/api/image/generate", json={"prompt": "a bird"})
assert gal_ok.status_code == 200
assert seen[0]["owner"] == "charlie"
def test_put_pref_clears_key_when_value_is_null(tmp_path, monkeypatch):
from routes import prefs_routes
from src import settings
prefs_file = str(tmp_path / "user_prefs.json")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", prefs_file)
# Set initial preference
prefs_routes._save_for_user("admin_user", {"image_model": "old-admin-model"})
assert prefs_routes._load_for_user("admin_user").get("image_model") == "old-admin-model"
app = FastAPI()
app.include_router(prefs_routes.setup_prefs_routes())
monkeypatch.setattr("src.auth_helpers.get_current_user", lambda request: "admin_user")
monkeypatch.setattr(prefs_routes, "get_current_user", lambda request: "admin_user")
with TestClient(app) as client:
res = client.put("/api/prefs/image_model", json={"value": None})
assert res.status_code == 200
assert res.json() == {"key": "image_model", "value": None}
# Verify key is deleted from user's record
user_prefs = prefs_routes._load_for_user("admin_user")
assert "image_model" not in user_prefs
# Verify get_user_setting now falls back to global setting
monkeypatch.setattr(settings, "load_settings", lambda: {"image_model": "new-global-model"})
assert settings.get_user_setting("image_model", owner="admin_user") == "new-global-model"