Add service admin groups, combined settings pages, single Settings button

- Auto-create {service-id}-admin groups at startup (group_bootstrap.py)
- get_service_admin() dep: grants access to superusers OR service group members
- /api/settings/ai and /api/settings/documents/limits now allow service admins
- AI service exposes /plugin/manifest (ai-service-admin access group)
- DocServiceSettingsPage: combined upload limits + watch directory on one page
- ServiceAdminRoute in frontend guards new /apps/documents/settings and /apps/ai/settings
- Single Settings button per app card (visible to admins and service group members)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-18 02:49:57 +02:00
parent 003fbee20f
commit c45236651b
15 changed files with 370 additions and 63 deletions
+25
View File
@@ -45,6 +45,31 @@ async def get_current_admin(
return current_user
def get_service_admin(service_id: str):
"""
Dependency factory that grants access to service-specific admin endpoints.
Access is granted if the user is a global superuser OR a member of the
'{service_id}-admin' group. Returns 404 (not 403) to hide both the
endpoint existence and the permission model.
Usage:
@router.get("/ai")
async def get_ai_settings(_: User = Depends(get_service_admin("ai-service"))):
"""
async def _dep(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> User:
if current_user.is_superuser:
return current_user
if await check_plugin_access(service_id, current_user, db):
return current_user
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
return _dep
async def check_plugin_access(
plugin_id: str,
current_user: User,
+5
View File
@@ -6,8 +6,10 @@ from fastapi.middleware.cors import CORSMiddleware
from app.core.app_config import seed_builtin_themes
from app.core.config import settings
from app.database import AsyncSessionLocal
from app.routers import admin, auth, categories_proxy, documents_proxy, groups, plugins, profile, services, users
from app.routers import settings as settings_router
from app.services.group_bootstrap import ensure_service_admin_groups
from app.services.service_health import check_all, health_check_loop, register_services
@@ -18,6 +20,9 @@ async def lifespan(app: FastAPI):
doc_service_url=settings.DOC_SERVICE_URL,
ai_service_url=settings.AI_SERVICE_URL,
)
# Create <service-id>-admin groups for every registered service (idempotent)
async with AsyncSessionLocal() as db:
await ensure_service_admin_groups(db)
# Run an initial check immediately so the first API response is accurate
await check_all()
task = asyncio.create_task(health_check_loop())
+8 -8
View File
@@ -31,7 +31,7 @@ from app.core.app_config import (
validate_theme_tokens,
)
from app.core.config import settings
from app.deps import get_current_admin, get_current_user
from app.deps import get_current_admin, get_current_user, get_service_admin
from app.models.user import User
router = APIRouter()
@@ -96,7 +96,7 @@ class ThemeUpdate(BaseModel):
@router.get("/ai")
async def get_ai_settings(
_: User = Depends(get_current_admin),
_: User = Depends(get_service_admin("ai-service")),
) -> dict:
return load_ai_service_config_masked()
@@ -104,7 +104,7 @@ async def get_ai_settings(
@router.patch("/ai")
async def update_ai_settings(
body: AIProviderUpdate,
_: User = Depends(get_current_admin),
_: User = Depends(get_service_admin("ai-service")),
) -> dict:
valid_providers = ("anthropic", "ollama", "lmstudio")
if body.provider not in valid_providers:
@@ -145,7 +145,7 @@ async def update_ai_settings(
@router.post("/ai/test")
async def test_ai_connection(
_: User = Depends(get_current_admin),
_: User = Depends(get_service_admin("ai-service")),
) -> dict:
"""Proxy a minimal chat request to ai-service to verify the connection."""
try:
@@ -171,7 +171,7 @@ async def test_ai_connection(
@router.get("/documents/limits")
async def get_documents_limits(
_: User = Depends(get_current_admin),
_: User = Depends(get_service_admin("doc-service")),
) -> dict:
return load_doc_service_config_masked()
@@ -179,7 +179,7 @@ async def get_documents_limits(
@router.patch("/documents/limits")
async def update_documents_limits(
body: LimitsUpdate,
_: User = Depends(get_current_admin),
_: User = Depends(get_service_admin("doc-service")),
) -> dict:
if body.max_pdf_mb < 1 or body.max_pdf_mb > 200:
raise HTTPException(status_code=422, detail="max_pdf_mb must be between 1 and 200")
@@ -195,7 +195,7 @@ async def update_documents_limits(
@router.get("/system-prompts")
async def get_system_prompts(
_: User = Depends(get_current_admin),
_: User = Depends(get_service_admin("ai-service")),
) -> dict:
"""Return all editable system prompts, keyed by service id."""
return await asyncio.to_thread(load_all_system_prompts)
@@ -205,7 +205,7 @@ async def get_system_prompts(
async def update_system_prompt(
service_id: str,
body: SystemPromptUpdate,
_: User = Depends(get_current_admin),
_: User = Depends(get_service_admin("ai-service")),
) -> dict:
"""Update the system prompts for a single service."""
if service_id not in SYSTEM_PROMPT_SERVICES:
+37
View File
@@ -0,0 +1,37 @@
"""
Ensure that every registered service has a corresponding admin group.
Called once at startup after register_services(). Idempotent — safe to run
on every restart, creates nothing if groups already exist.
Naming convention: "{service_id}-admin" (e.g. "doc-service-admin")
"""
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.group import Group
from app.services.service_health import get_registry
logger = logging.getLogger(__name__)
async def ensure_service_admin_groups(db: AsyncSession) -> None:
"""Create a <service-id>-admin group for each registered service if absent."""
for svc in get_registry():
group_name = f"{svc.id}-admin"
result = await db.execute(select(Group).where(Group.name == group_name))
if result.scalar_one_or_none() is not None:
continue
import uuid
group = Group(
id=str(uuid.uuid4()),
name=group_name,
description=f"Administrators for the {svc.name} service.",
)
db.add(group)
logger.info("[bootstrap] Created admin group %r for service %r", group_name, svc.id)
await db.commit()
+2 -2
View File
@@ -52,7 +52,7 @@ def register_services(doc_service_url: str, ai_service_url: str) -> None:
internal_url=doc_service_url,
health_path="/health",
app_path="/apps/documents",
settings_path="/apps/documents/settings/admin",
settings_path="/apps/documents/settings",
),
ServiceDefinition(
id="ai-service",
@@ -61,7 +61,7 @@ def register_services(doc_service_url: str, ai_service_url: str) -> None:
internal_url=ai_service_url,
health_path="/health",
app_path="",
settings_path="/apps/ai/settings/admin",
settings_path="/apps/ai/settings",
),
]