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:
+12
-6
@@ -66,14 +66,18 @@ A background task (`service_health.py`) polls each service's `/health` endpoint
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/settings/ai` | AI service config (masked — API keys redacted) |
|
||||
| `PATCH` | `/api/settings/ai` | Update AI provider / credentials |
|
||||
| `POST` | `/api/settings/ai/test` | Test AI connection (proxies a minimal /chat call) |
|
||||
| `GET` | `/api/settings/documents/limits` | Doc service upload limits |
|
||||
| `PATCH` | `/api/settings/documents/limits` | Update max PDF size |
|
||||
| `GET` | `/api/settings/ai` | AI service config (masked) — superuser OR `ai-service-admin` member |
|
||||
| `PATCH` | `/api/settings/ai` | Update AI provider / credentials — same access |
|
||||
| `POST` | `/api/settings/ai/test` | Test AI connection — same access |
|
||||
| `GET` | `/api/settings/documents/limits` | Doc service upload limits — superuser OR `doc-service-admin` member |
|
||||
| `PATCH` | `/api/settings/documents/limits` | Update max PDF size — same access |
|
||||
| `GET` | `/api/settings/system-prompts` | All editable system prompts — superuser OR `ai-service-admin` member |
|
||||
| `PATCH` | `/api/settings/system-prompts/{id}` | Update system prompt — same access |
|
||||
|
||||
Settings are persisted to JSON files on the `app_config` Docker named volume and read by the respective feature services.
|
||||
|
||||
Access to service-specific settings endpoints is enforced by `get_service_admin(service_id)` in `deps.py` — grants access to superusers OR members of the `{service_id}-admin` group.
|
||||
|
||||
### Feature proxies
|
||||
|
||||
All `/api/documents/*` and `/api/documents/categories/*` requests are transparently proxied to `doc-service:8001` via `httpx.AsyncClient`. The proxy:
|
||||
@@ -95,7 +99,9 @@ Generic extension/plugin infrastructure — **zero feature-specific code in back
|
||||
|
||||
Access is controlled by the manifest: `allow_superuser` for admins; `required_groups` for group members. `check_plugin_access(plugin_id, user, db)` in `deps.py` enforces this.
|
||||
|
||||
During each health poll, `service_health.py` also fetches `GET /plugin/manifest` from healthy services and caches it. New feature containers that expose `/plugin/manifest` automatically appear in the Extensions sidebar — no backend code changes required.
|
||||
During each health poll, `service_health.py` also fetches `GET /plugin/manifest` from healthy services and caches it. New feature containers that expose `/plugin/manifest` automatically appear in the plugin list — no backend code changes required.
|
||||
|
||||
**Service admin group bootstrap:** On every startup, `group_bootstrap.py` creates a `{service-id}-admin` group for every registered service (idempotent). Admins add users to these groups via the Admin → Groups UI to delegate service-level administration.
|
||||
|
||||
### Database models
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user