diff --git a/CLAUDE.md b/CLAUDE.md index b107fb8..9062c09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ lembas info # paths + counts, useful when confused lembas secret-key # generate LEMBAS_SECRET_KEY lembas create-admin # create or promote an admin -pytest # 70 tests, ~2s +pytest # 143 tests, ~5s ruff check . # lint (line length 100) python scripts/build_artwork.py # regenerate all SVG artwork python scripts/fetch_vendor.py # verify vendored JS against the lockfile @@ -38,9 +38,10 @@ redesign, not a tweak. 3. **No hard-coded colours outside `tokens.css`.** Every colour, space and radius resolves through a CSS variable. That is what makes a new theme one new block rather than an audit of every stylesheet. -4. **No migration tool.** SQLite only, schema created at startup by - `init_db()`, which is `CREATE TABLE IF NOT EXISTS` and never alters an - existing table. See "Changing the schema" below. +4. **Additive-only schema changes.** SQLite only, no Alembic. `init_db()` runs + `db/migrations.py:sync_schema()`, which creates missing tables *and* adds + missing columns by diffing the models against the database. Renames, drops + and retypes are still manual. See "Changing the schema" below. 5. **Secrets never reach the browser.** API keys are Fernet-encrypted at rest and only ever rendered masked. 6. **Model output is untrusted.** Everything from an endpoint goes through @@ -71,18 +72,24 @@ src/lembas/ pages.py full-page routes (chat shell, settings) chats.py messaging + the SSE stream folders.py folder CRUD - admin.py connections + models - preferences.py per-user theme + admin.py connections + instance settings + admin_models.py model ordering, defaults, images, access + admin_users.py users, groups, permissions + preferences.py per-user theme, default model, password db/ base.py Base, UUID/Timestamp mixins session.py engine, SQLite pragmas, init_db, session_scope + migrations.py additive schema sync (tables + columns) models/ user, chat, connection, setting - security/ passwords (argon2), sessions + security/ passwords (argon2), sessions, permissions services/ llm/openai_client.py httpx streaming + model discovery chat.py request building, endpoint resolution, titles markdown.py markdown-it + pygments + nh3 crypto.py Fernet encrypt/decrypt/mask + reasoning.py splits thinking from the answer + settings_store.py runtime instance settings + uploads.py validated image storage sse.py event framing web/ templating.py render() -- always use this, not TemplateResponse @@ -138,20 +145,46 @@ settings an admin edits at runtime, stored in the `settings` table. Environment variables seed the latter as an *initial* value only — once stored, the database wins, or a toggle in the UI would silently revert on the next restart. +**Permissions are a union, and admins bypass them.** `security/permissions.py` +resolves a baseline (instance setting) widened by each group. A group grants; +it never denies — otherwise "why can this user not do X" needs a simulation of +every group to answer. Model *access* is separate: `models_visible_to()`. + +**FastAPI cannot tell an empty form field from an absent one.** With +`x: str | None = Form(None)`, a submitted `x=` arrives as `None`, so "clear this +field" is indistinguishable from "leave it alone". `api/chats.py:update_chat` +reads `await request.form()` and checks key presence instead. Anything with a +clearable field must do the same. + +**`Mapped[list]` without an element type is not a collection.** SQLAlchemy +treats a bare `Mapped[list]` as a scalar and hands back `None` instead of `[]`. +Always write `Mapped[list[Group]]`, with a `TYPE_CHECKING` import if the class +lives in another module. + +**Reasoning arrives two ways.** A `reasoning_content` delta field (llama.cpp, +llama-swap, vLLM) or `` tags inline in `content` (Ollama and friends). +`services/reasoning.py` handles the second with a streaming splitter, because +the tags arrive split across chunks. Reasoning is stored in `Message.reasoning` +and is deliberately **not** replayed as context on the next turn. + **JSON columns need reassignment.** `user.settings_json["theme"] = x` on a plain dict is not detected. The columns use `MutableDict` (`db/types.py`), but the safe habit is `obj.field = {**obj.field, "k": v}`. ## Changing the schema -There is no Alembic. `init_db()` creates missing tables and nothing else, so -adding a column to a model does **not** add it to an existing database. For a -live install: `ALTER TABLE` by hand, or delete the database if the data is -disposable. +There is no Alembic, but there *is* `db/migrations.py`. It compares the declared +models against the live database and issues `ALTER TABLE ... ADD COLUMN` for +anything missing, so adding a column to a model is free: restart and it appears, +with existing rows backfilled from a type-derived default. -This is why `Message.parent_id` and `Message.content_parts_json` already exist -though nothing reads them — they are for branching and multimodal turns, and -retrofitting them later would be the painful path. +It cannot rename, drop or retype a column, or add a UNIQUE/PRIMARY KEY to an +existing table — SQLite mostly cannot do those with ALTER TABLE either. Those +need the create-copy-swap dance by hand; record them in `MANUAL_STEPS` so a +failure has somewhere to point. + +Because the runner exists, forward-looking columns are cheap now. `Message.parent_id` +and `content_parts_json` (branching, multimodal) predate it and are still unread. ## Artwork @@ -182,7 +215,9 @@ notes describe the machine. ## Not built yet -Users & groups UI (the tables exist), file upload / vision / PDFs, built-in -tools + admin tool settings, custom tools and MCP, agentic execution (local -subprocess and SSH connection profiles), image generation. Empty packages and -nav entries mark where each one goes. +File upload / vision / PDFs, built-in tools + admin tool settings, custom tools +and MCP, agentic execution (local subprocess and SSH connection profiles), image +generation. Nav entries mark where each one goes. + +`Model.capabilities_json` already carries `vision` and `tools` flags that +nothing reads yet — they are admin overrides waiting for those features. diff --git a/README.md b/README.md index 4bc8c2d..06641af 100644 --- a/README.md +++ b/README.md @@ -31,22 +31,30 @@ runtime. Clone it, `pip install -e .`, run it. **Working now** - **Chats** — streaming replies, Markdown with server-side syntax highlighting, - copy and regenerate, automatic chat titles + copy and regenerate, automatic chat titles, per-chat system prompt and + sampling settings +- **Reasoning display** — thinking from reasoning models streams into its own + collapsible block, labelled with how long it took, and is never replayed as + context - **Folders** — arbitrarily nested, delete a folder without losing the chats inside it - **OpenAI connections** — point at OpenAI, LM Studio, vLLM, llama.cpp, llama-swap, Ollama or OpenRouter; models are discovered and cached +- **Model settings** — ordering, pinned models, an instance default and a + per-user default, custom names and descriptions, uploaded model images +- **Users, groups & permissions** — per-group grants that union rather than + override, and model access restricted to chosen groups - **Accounts** — first account becomes the administrator, argon2 password - hashing, revocable server-side sessions, self-service password change + hashing, revocable server-side sessions, self-service password change, + admin-managed accounts - **Admin settings** — open or close registration from the UI, stored in the database and effective immediately - **Two themes** — *Moria* (dark) and *Shire* (light), switchable per user **Planned** -Users & groups with permissions · file upload, vision and PDFs · built-in tools -with admin settings · custom tools and MCP servers · agentic execution (local -and over SSH) · image generation. +File upload, vision and PDFs · built-in tools with admin settings · custom tools +and MCP servers · agentic execution (local and over SSH) · image generation. ## Quick start @@ -132,8 +140,10 @@ python scripts/build_artwork.py # regenerate the SVG artwork python scripts/fetch_vendor.py # verify vendored JS against the lockfile ``` -There is no migration tool. The schema is SQLite-only and created at startup, -so changing a column on a live database is a manual job — see `CLAUDE.md`. +There is no Alembic. The schema is SQLite-only and synchronised at startup: +missing tables and missing columns are added automatically, so adding a field to +a model needs nothing but a restart. Renames, drops and retypes are still manual +— see `CLAUDE.md`. ## Artwork diff --git a/src/lembas/api/admin.py b/src/lembas/api/admin.py index 764526a..ee97289 100644 --- a/src/lembas/api/admin.py +++ b/src/lembas/api/admin.py @@ -193,12 +193,17 @@ async def _refresh_models(db: DBSession, connection: Connection) -> tuple[int, s existing = {model.model_id: model for model in connection.models} seen: set[str] = set() + # New models land after everything already ordered, rather than all at + # position 0 where they would sort by id and shuffle the existing list. + next_position = (db.scalar(select(func.coalesce(func.max(Model.position), -1))) or -1) + 1 + for entry in discovered: model_id = str(entry["id"])[:300] seen.add(model_id) if model_id in existing: continue - db.add(Model(connection_id=connection.id, model_id=model_id)) + db.add(Model(connection_id=connection.id, model_id=model_id, position=next_position)) + next_position += 1 # Models that vanished upstream are dropped, so the picker never offers # something the endpoint will reject. @@ -219,19 +224,3 @@ async def delete_connection(db: Db, user: AdminUser, connection_id: str) -> Resp db.delete(connection) db.commit() return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER) - - -@router.get("/models") -async def models_page(request: Request, db: Db, user: AdminUser): - connections = _connections(db) - return render(request, "admin/models.html", {"connections": connections}) - - -@router.post("/models/{model_id}/toggle") -async def toggle_model(db: Db, user: AdminUser, model_id: str) -> Response: - model = db.get(Model, model_id) - if model is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.") - model.enabled = not model.enabled - db.commit() - return RedirectResponse("/admin/models", status_code=status.HTTP_303_SEE_OTHER) diff --git a/src/lembas/api/admin_models.py b/src/lembas/api/admin_models.py new file mode 100644 index 0000000..0e0c406 --- /dev/null +++ b/src/lembas/api/admin_models.py @@ -0,0 +1,212 @@ +"""Model administration: ordering, defaults, images, access and capabilities.""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile, status +from fastapi.responses import FileResponse, RedirectResponse +from sqlalchemy import select +from sqlalchemy.orm import Session as DBSession + +from lembas.api.deps import AdminUser, Db, RequiredUser +from lembas.db.models import Connection, Group, Model +from lembas.services import settings_store, uploads +from lembas.web.templating import render + +log = logging.getLogger(__name__) + +router = APIRouter(tags=["admin-models"]) + +CAPABILITIES = ("reasoning", "vision", "tools") + + +def _model(db: DBSession, model_id: str) -> Model: + model = db.get(Model, model_id) + if model is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.") + return model + + +def _ordered(db: DBSession) -> list[Model]: + return list( + db.scalars( + select(Model).join(Connection).order_by(Model.position, Model.model_id) + ) + ) + + +def _renumber(db: DBSession) -> None: + """Rewrite positions to 0..n-1. + + Keeps the numbers dense so a move is always a swap with a neighbour, and + stops repeated reordering drifting into large sparse values. + """ + for index, model in enumerate(_ordered(db)): + model.position = index + db.commit() + + +# --- The admin page ---------------------------------------------------------- +@router.get("/admin/models") +async def models_page(request: Request, db: Db, user: AdminUser, saved: str = ""): + models = _ordered(db) + return render( + request, + "admin/models.html", + { + "models": models, + "groups": list(db.scalars(select(Group).order_by(Group.name))), + "connections": list(db.scalars(select(Connection).order_by(Connection.name))), + "default_model": settings_store.get(db, "default_model") or "", + "capabilities": CAPABILITIES, + "saved": saved, + }, + ) + + +@router.post("/admin/models/{model_id}") +async def update_model( + db: Db, + user: AdminUser, + model_id: str, + display_name: str = Form(""), + description: str = Form(""), + enabled: bool = Form(False), + pinned: bool = Form(False), + public: bool = Form(False), + group_ids: list[str] = Form(default=[]), + capability: list[str] = Form(default=[]), +) -> Response: + model = _model(db, model_id) + + model.display_name = display_name.strip()[:300] + model.description = description.strip()[:2000] + model.enabled = enabled + model.pinned = pinned + model.public = public + + # Absent checkboxes are simply missing from a form post, so the submitted + # list IS the complete new state -- rebuild rather than merge. + model.capabilities_json = {name: (name in capability) for name in CAPABILITIES} + + if public: + # Group rows would be dead weight and misleading in the UI. + model.groups = [] + else: + model.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or [])))) + + db.commit() + log.info("model %s updated by %s", model.model_id, user.email) + return RedirectResponse("/admin/models?saved=Model+saved.", status_code=303) + + +@router.post("/admin/models/{model_id}/move") +async def move_model( + db: Db, user: AdminUser, model_id: str, direction: str = Form(...) +) -> Response: + """Swap a model with its neighbour.""" + model = _model(db, model_id) + ordered = _ordered(db) + index = next((i for i, m in enumerate(ordered) if m.id == model.id), None) + + if index is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.") + + target = index - 1 if direction == "up" else index + 1 + if 0 <= target < len(ordered): + ordered[index], ordered[target] = ordered[target], ordered[index] + for position, item in enumerate(ordered): + item.position = position + db.commit() + + return RedirectResponse("/admin/models", status_code=303) + + +@router.post("/admin/models/{model_id}/default") +async def set_default_model(db: Db, user: AdminUser, model_id: str) -> Response: + """Make a model the instance default for new chats.""" + model = _model(db, model_id) + settings_store.update(db, {"default_model": model.model_id}) + log.info("default model set to %s by %s", model.model_id, user.email) + return RedirectResponse( + f"/admin/models?saved={model.label}+is+now+the+default.", status_code=303 + ) + + +@router.post("/admin/models/{model_id}/image") +async def upload_model_image( + db: Db, user: AdminUser, model_id: str, image: UploadFile = File(...) +) -> Response: + model = _model(db, model_id) + payload = await image.read() + + try: + filename = uploads.save_model_image(payload, image.content_type or "") + except uploads.UploadError as exc: + return RedirectResponse(f"/admin/models?saved={exc}", status_code=303) + + # Remove the old file rather than orphaning it in the uploads directory. + if model.image_path: + uploads.delete_model_image(model.image_path) + + model.image_path = filename + db.commit() + return RedirectResponse("/admin/models?saved=Image+updated.", status_code=303) + + +@router.post("/admin/models/{model_id}/image/delete") +async def delete_model_image(db: Db, user: AdminUser, model_id: str) -> Response: + model = _model(db, model_id) + if model.image_path: + uploads.delete_model_image(model.image_path) + model.image_path = "" + db.commit() + return RedirectResponse("/admin/models?saved=Image+removed.", status_code=303) + + +@router.post("/admin/models/bulk") +async def bulk_models( + db: Db, user: AdminUser, action: str = Form(...), model_ids: list[str] = Form(default=[]) +) -> Response: + """Enable or disable several models at once. + + A freshly refreshed connection can advertise dozens of models; turning them + off one at a time is not a reasonable way to spend an afternoon. + """ + models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or [])))) + for model in models: + if action == "enable": + model.enabled = True + elif action == "disable": + model.enabled = False + elif action == "public": + model.public = True + model.groups = [] + elif action == "private": + model.public = False + db.commit() + _renumber(db) + return RedirectResponse( + f"/admin/models?saved={len(models)}+model(s)+updated.", status_code=303 + ) + + +# --- Serving model images ---------------------------------------------------- +@router.get("/uploads/models/{filename}") +async def model_image(user: RequiredUser, filename: str) -> Response: + """Serve a stored model avatar. + + Behind the auth guard: these are instance assets, not public files, and + the path resolution in uploads refuses anything outside the directory. + """ + path = uploads.model_image_path(filename) + if path is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No such image.") + return FileResponse( + path, + media_type=uploads.media_type_for(filename), + # Filenames are random and content-addressed in practice, so a long + # cache is safe: a new image gets a new name. + headers={"Cache-Control": "private, max-age=604800"}, + ) diff --git a/src/lembas/api/admin_users.py b/src/lembas/api/admin_users.py new file mode 100644 index 0000000..0695d65 --- /dev/null +++ b/src/lembas/api/admin_users.py @@ -0,0 +1,268 @@ +"""User and group administration.""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Form, HTTPException, Request, Response, status +from fastapi.responses import RedirectResponse +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session as DBSession + +from lembas.api.deps import AdminUser, Db +from lembas.db.models import ROLE_ADMIN, ROLE_PENDING, ROLE_USER, Group, Model, User +from lembas.security import permissions +from lembas.security.passwords import hash_password, validate_password +from lembas.security.sessions import revoke_all_for_user +from lembas.services import settings_store +from lembas.web.templating import render + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin", tags=["admin-users"]) + +ROLES = (ROLE_ADMIN, ROLE_USER, ROLE_PENDING) + + +def _user(db: DBSession, user_id: str) -> User: + found = db.get(User, user_id) + if found is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "That user no longer exists.") + return found + + +def _group(db: DBSession, group_id: str) -> Group: + found = db.get(Group, group_id) + if found is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "That group no longer exists.") + return found + + +def _admin_count(db: DBSession) -> int: + return db.scalar( + select(func.count()).select_from(User).where(User.role == ROLE_ADMIN, User.active.is_(True)) + ) + + +def _would_orphan_the_instance(db: DBSession, user: User) -> bool: + """True if changing this user would leave nobody able to administer. + + An instance with no active administrator can only be recovered from the + command line, so every path that could cause it is blocked in the UI. + """ + return user.role == ROLE_ADMIN and user.active and _admin_count(db) <= 1 + + +# --- Users ------------------------------------------------------------------- +@router.get("/users") +async def users_page(request: Request, db: Db, user: AdminUser, q: str = "", saved: str = ""): + query = select(User).order_by(User.created_at) + if q.strip(): + pattern = f"%{q.strip()}%" + query = query.where(or_(User.name.ilike(pattern), User.email.ilike(pattern))) + + return render( + request, + "admin/users.html", + { + "users": list(db.scalars(query)), + "groups": list(db.scalars(select(Group).order_by(Group.name))), + "roles": ROLES, + "q": q, + "saved": saved, + "admin_count": _admin_count(db), + }, + ) + + +@router.post("/users") +async def create_user( + db: Db, + user: AdminUser, + name: str = Form(...), + email: str = Form(...), + password: str = Form(...), + role: str = Form(ROLE_USER), +) -> Response: + """Create an account directly, without going through registration.""" + email = email.strip().lower() + if (problem := validate_password(password)) is not None: + return RedirectResponse(f"/admin/users?saved={problem}", status_code=303) + if db.scalar(select(User).where(User.email == email)) is not None: + return RedirectResponse( + "/admin/users?saved=That+email+is+already+registered.", status_code=303 + ) + + db.add( + User( + name=name.strip()[:120] or email, + email=email, + password_hash=hash_password(password), + role=role if role in ROLES else ROLE_USER, + ) + ) + db.commit() + log.info("%s created account %s", user.email, email) + return RedirectResponse(f"/admin/users?saved=Created+{email}.", status_code=303) + + +@router.post("/users/{user_id}") +async def update_user( + db: Db, + user: AdminUser, + user_id: str, + name: str = Form(...), + role: str = Form(ROLE_USER), + active: bool = Form(False), + group_ids: list[str] = Form(default=[]), +) -> Response: + target = _user(db, user_id) + + losing_admin = target.role == ROLE_ADMIN and (role != ROLE_ADMIN or not active) + if losing_admin and _would_orphan_the_instance(db, target): + return RedirectResponse( + "/admin/users?saved=That+is+the+only+administrator.+Promote+someone+else+first.", + status_code=303, + ) + + target.name = name.strip()[:120] or target.name + target.role = role if role in ROLES else target.role + target.active = active + target.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or [])))) + + # A deactivated or demoted user must lose their live sessions immediately, + # otherwise the change only takes effect when their cookie happens to expire. + if not active: + revoke_all_for_user(db, target) + + db.commit() + log.info("%s updated account %s (role=%s active=%s)", user.email, target.email, role, active) + return RedirectResponse(f"/admin/users?saved=Saved+{target.email}.", status_code=303) + + +@router.post("/users/{user_id}/password") +async def reset_password( + db: Db, user: AdminUser, user_id: str, password: str = Form(...) +) -> Response: + target = _user(db, user_id) + if (problem := validate_password(password)) is not None: + return RedirectResponse(f"/admin/users?saved={problem}", status_code=303) + + target.password_hash = hash_password(password) + db.commit() + # Everywhere that account was signed in is now signed out. An admin reset + # usually means the account is compromised or the person is gone. + revoke_all_for_user(db, target) + log.info("%s reset the password for %s", user.email, target.email) + return RedirectResponse( + f"/admin/users?saved=Password+reset+for+{target.email}.+Sessions+revoked.", + status_code=303, + ) + + +@router.post("/users/{user_id}/delete") +async def delete_user(db: Db, user: AdminUser, user_id: str) -> Response: + target = _user(db, user_id) + + if target.id == user.id: + return RedirectResponse( + "/admin/users?saved=You+cannot+delete+your+own+account.", status_code=303 + ) + if _would_orphan_the_instance(db, target): + return RedirectResponse( + "/admin/users?saved=That+is+the+only+administrator.", status_code=303 + ) + + email = target.email + # Chats and folders cascade; that is the point of deleting an account. + db.delete(target) + db.commit() + log.info("%s deleted account %s", user.email, email) + return RedirectResponse(f"/admin/users?saved=Deleted+{email}.", status_code=303) + + +# --- Groups ------------------------------------------------------------------ +@router.get("/groups") +async def groups_page(request: Request, db: Db, user: AdminUser, saved: str = ""): + return render( + request, + "admin/groups.html", + { + "groups": list(db.scalars(select(Group).order_by(Group.name))), + "users": list(db.scalars(select(User).order_by(User.name))), + "models": list(db.scalars(select(Model).order_by(Model.position, Model.model_id))), + "permission_groups": permissions.permission_groups(), + "baseline": permissions.baseline_permissions(db), + "saved": saved, + }, + ) + + +@router.post("/groups") +async def create_group(db: Db, user: AdminUser, name: str = Form(...)) -> Response: + name = name.strip()[:120] + if not name: + return RedirectResponse("/admin/groups?saved=A+group+needs+a+name.", status_code=303) + if db.scalar(select(Group).where(Group.name == name)) is not None: + return RedirectResponse( + "/admin/groups?saved=A+group+with+that+name+already+exists.", status_code=303 + ) + + db.add(Group(name=name)) + db.commit() + log.info("%s created group %s", user.email, name) + return RedirectResponse(f"/admin/groups?saved=Created+{name}.", status_code=303) + + +@router.post("/groups/{group_id}") +async def update_group( + db: Db, + user: AdminUser, + group_id: str, + name: str = Form(...), + description: str = Form(""), + permission: list[str] = Form(default=[]), + user_ids: list[str] = Form(default=[]), + model_ids: list[str] = Form(default=[]), +) -> Response: + group = _group(db, group_id) + + group.name = name.strip()[:120] or group.name + group.description = description.strip()[:1000] + # The submitted checkbox list is the complete new state; absent means the + # group does not grant that permission, not that it denies it. + group.permissions_json = {key: True for key in permission if key in permissions.PERMISSION_KEYS} + group.users = list(db.scalars(select(User).where(User.id.in_(user_ids or [])))) + group.models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or [])))) + + db.commit() + log.info("%s updated group %s", user.email, group.name) + return RedirectResponse(f"/admin/groups?saved=Saved+{group.name}.", status_code=303) + + +@router.post("/groups/{group_id}/delete") +async def delete_group(db: Db, user: AdminUser, group_id: str) -> Response: + group = _group(db, group_id) + name = group.name + # Members and model links go with it; the users themselves are untouched. + db.delete(group) + db.commit() + log.info("%s deleted group %s", user.email, name) + return RedirectResponse(f"/admin/groups?saved=Deleted+{name}.", status_code=303) + + +@router.post("/permissions/defaults") +async def save_baseline( + db: Db, user: AdminUser, permission: list[str] = Form(default=[]) +) -> Response: + """The permissions every user has before any group widens them.""" + settings_store.update( + db, + { + "default_permissions": { + key: (key in permission) for key in permissions.PERMISSION_KEYS + } + }, + ) + log.info("%s changed the baseline permissions", user.email) + return RedirectResponse("/admin/groups?saved=Default+permissions+saved.", status_code=303) diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index 1bdaf43..c5a941c 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -4,19 +4,27 @@ from __future__ import annotations import asyncio import logging +import time from collections.abc import AsyncIterator -from fastapi import APIRouter, Form, HTTPException, Request, status +from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from fastapi.responses import HTMLResponse, Response, StreamingResponse from sqlalchemy.orm import Session as DBSession -from lembas.api.deps import Db, RequiredUser +from lembas.api.deps import Db, RequiredUser, require_permission from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User from lembas.db.session import session_scope +from lembas.security import permissions from lembas.services import chat as chat_service from lembas.services import sse -from lembas.services.llm.openai_client import LLMError, delta_text, stream_chat +from lembas.services.llm.openai_client import ( + LLMError, + delta_reasoning, + delta_text, + stream_chat, +) from lembas.services.markdown import escape_text, render_markdown +from lembas.services.reasoning import REASONING, ReasoningSplitter from lembas.web.templating import render, templates log = logging.getLogger(__name__) @@ -33,9 +41,9 @@ def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat: return chat -@router.post("") +@router.post("", dependencies=[Depends(require_permission("chat.create"))]) async def create_chat(db: Db, user: RequiredUser, folder_id: str = Form("")) -> Response: - chosen = chat_service.default_model(db) + chosen = chat_service.default_model(db, user) chat = Chat( user_id=user.id, folder_id=folder_id or None, @@ -130,7 +138,9 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]: be closed by the time the first token arrives. """ accumulated: list[str] = [] + thinking: list[str] = [] error: str | None = None + reasoning_ms = 0 with session_scope() as db: chat = db.get(Chat, chat_id) @@ -140,6 +150,12 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]: return first_user_text = "" + # Handles models that emit tags inline in content rather than + # using the reasoning_content field. + splitter = ReasoningSplitter() + started = time.monotonic() + reasoning_started: float | None = None + try: endpoint, model_id = chat_service.resolve_endpoint(db, chat) payload = chat_service.build_request(db, chat, upto=message) @@ -149,14 +165,42 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]: ) async for chunk in stream_chat(endpoint, payload): + # A dedicated reasoning field is unambiguous; take it as-is. + thought = delta_reasoning(chunk) + if thought: + if reasoning_started is None: + reasoning_started = time.monotonic() + thinking.append(thought) + yield sse.event("reasoning", escape_text(thought)) + await asyncio.sleep(0) + text = delta_text(chunk) if not text: continue - accumulated.append(text) - yield sse.event("token", escape_text(text)) - # Hand control back so the event is flushed rather than - # batched behind a fast generator. - await asyncio.sleep(0) + + for kind, piece in splitter.feed(text): + if kind == REASONING: + if reasoning_started is None: + reasoning_started = time.monotonic() + thinking.append(piece) + yield sse.event("reasoning", escape_text(piece)) + else: + # First answer token ends the thinking phase. + if reasoning_started is not None and not reasoning_ms: + reasoning_ms = int((time.monotonic() - reasoning_started) * 1000) + accumulated.append(piece) + yield sse.event("token", escape_text(piece)) + # Hand control back so the event is flushed rather than + # batched behind a fast generator. + await asyncio.sleep(0) + + for kind, piece in splitter.flush(): + if kind == REASONING: + thinking.append(piece) + yield sse.event("reasoning", escape_text(piece)) + else: + accumulated.append(piece) + yield sse.event("token", escape_text(piece)) except LLMError as exc: error = exc.message @@ -165,6 +209,7 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]: # The reader navigated away or closed the tab. Keep whatever was # produced so the partial reply is still there on reload. message.content = "".join(accumulated) + message.reasoning = "".join(thinking) message.complete = True db.commit() raise @@ -172,9 +217,22 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]: error = "Something went wrong while generating this reply." log.exception("unexpected generation failure for chat %s: %s", chat_id, exc) + if reasoning_started is not None and not reasoning_ms: + # Reasoning ran to the end without an answer following it. + reasoning_ms = int((time.monotonic() - reasoning_started) * 1000) + message.content = "".join(accumulated) + message.reasoning = "".join(thinking) + message.reasoning_ms = reasoning_ms message.error = error or "" message.complete = True + log.debug( + "chat %s: %d chars answer, %d chars reasoning, %.1fs total", + chat_id, + len(message.content), + len(message.reasoning), + time.monotonic() - started, + ) if not chat.title_generated and (accumulated or error): chat.title = ( @@ -208,38 +266,103 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]: @router.patch("/{chat_id}") -async def update_chat( - db: Db, - user: RequiredUser, - chat_id: str, - title: str | None = Form(None), - folder_id: str | None = Form(None), - model_id: str | None = Form(None), -) -> Response: - chat = _owned_chat(db, chat_id, user.id) +async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str) -> Response: + """Partially update a chat. - if title is not None: - cleaned = title.strip()[:300] + The raw form is read rather than declaring Form() parameters because + FastAPI substitutes the default for an empty form value, which makes + "field absent" and "field submitted empty" indistinguishable. That + difference is exactly what this endpoint needs: an empty system prompt or + temperature means *clear it*, not *leave it alone*. + """ + chat = _owned_chat(db, chat_id, user.id) + allowed = permissions.resolve(db, user) + form = await request.form() + + if "title" in form: + cleaned = str(form["title"]).strip()[:300] if cleaned: chat.title = cleaned # An explicit rename must not be overwritten by auto-titling later. chat.title_generated = True - if folder_id is not None: - chat.folder_id = folder_id or None + if "folder_id" in form: + chat.folder_id = str(form["folder_id"]) or None - if model_id is not None and model_id: - chat.model_id = model_id + model_id = str(form.get("model_id", "")).strip() + + if model_id: + if not allowed.get("chat.model_select"): + raise HTTPException( + status.HTTP_403_FORBIDDEN, "You may not change the model for a chat." + ) + # Checked against what this user can reach, not merely what exists -- + # otherwise the picker is advisory and a crafted request bypasses it. match = next( - (m for m in chat_service.available_models(db) if m.model_id == model_id), None + (m for m in chat_service.available_models(db, user) if m.model_id == model_id), + None, ) - chat.connection_id = match.connection_id if match else None + if match is None: + raise HTTPException(status.HTTP_403_FORBIDDEN, "That model is not available to you.") + chat.model_id = model_id + chat.connection_id = match.connection_id + + if "system_prompt" in form: + if not allowed.get("chat.system_prompt"): + raise HTTPException( + status.HTTP_403_FORBIDDEN, "You may not set a system prompt." + ) + chat.system_prompt = str(form["system_prompt"]).strip()[:8000] + + submitted_params = {name: form[name] for name in _PARAM_RANGES if name in form} + if submitted_params: + if not allowed.get("chat.params"): + raise HTTPException( + status.HTTP_403_FORBIDDEN, "You may not change sampling parameters." + ) + chat.params_json = { + **(chat.params_json or {}), + **_clean_params(**{k: str(v) for k, v in submitted_params.items()}), + } db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) -@router.delete("/{chat_id}") +# Bounds are the ones every provider agrees on. Out-of-range values are +# dropped rather than clamped: silently changing what someone typed is worse +# than ignoring it, and the form shows what actually stuck on reload. +_PARAM_RANGES: dict[str, tuple[type, float, float]] = { + "temperature": (float, 0.0, 2.0), + "top_p": (float, 0.0, 1.0), + "max_tokens": (int, 1, 1_000_000), +} + + +def _clean_params(**submitted: str | None) -> dict[str, float | int | None]: + """Parse sampling parameters, dropping anything unusable. + + An empty string means "unset this and let the provider default apply", so + it maps to None rather than being ignored. + """ + cleaned: dict[str, float | int | None] = {} + for name, raw in submitted.items(): + if raw is None: + continue + if not raw.strip(): + cleaned[name] = None + continue + caster, low, high = _PARAM_RANGES[name] + try: + value = caster(raw) + except (TypeError, ValueError): + continue + if low <= value <= high: + cleaned[name] = value + return cleaned + + +@router.delete("/{chat_id}", dependencies=[Depends(require_permission("chat.delete"))]) async def delete_chat(db: Db, user: RequiredUser, chat_id: str) -> Response: chat = _owned_chat(db, chat_id, user.id) db.delete(chat) diff --git a/src/lembas/api/deps.py b/src/lembas/api/deps.py index 711aeff..303378f 100644 --- a/src/lembas/api/deps.py +++ b/src/lembas/api/deps.py @@ -78,6 +78,27 @@ def require_admin(user: RequiredUser) -> User: AdminUser = Annotated[User, Depends(require_admin)] +def require_permission(key: str): + """Dependency factory guarding a route behind a named permission. + + @router.post("", dependencies=[Depends(require_permission("chat.create"))]) + + Administrators always pass; see lembas.security.permissions for why. + """ + + def guard(db: Db, user: RequiredUser) -> User: + from lembas.security import permissions + + if not permissions.has(db, user, key): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to do that.", + ) + return user + + return guard + + def is_htmx(request: Request) -> bool: return request.headers.get("HX-Request") == "true" diff --git a/src/lembas/api/folders.py b/src/lembas/api/folders.py index 606da24..3b8a447 100644 --- a/src/lembas/api/folders.py +++ b/src/lembas/api/folders.py @@ -2,13 +2,18 @@ from __future__ import annotations -from fastapi import APIRouter, Form, HTTPException, Response, status +from fastapi import APIRouter, Depends, Form, HTTPException, Response, status from sqlalchemy.orm import Session as DBSession -from lembas.api.deps import Db, RequiredUser +from lembas.api.deps import Db, RequiredUser, require_permission from lembas.db.models import Folder -router = APIRouter(prefix="/api/folders", tags=["folders"]) +# Every route here manages folders, so the guard belongs on the router. +router = APIRouter( + prefix="/api/folders", + tags=["folders"], + dependencies=[Depends(require_permission("folder.manage"))], +) MAX_DEPTH = 8 diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index 21138c2..bcb7c9b 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session as DBSession from lembas.api.deps import Db, RequiredUser from lembas.db.models import Chat, Folder, Message, User +from lembas.security import permissions from lembas.services import chat as chat_service from lembas.services.markdown import render_markdown from lembas.web.templating import render @@ -16,6 +17,26 @@ from lembas.web.templating import render router = APIRouter(tags=["pages"]) +def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict: + """Model lists and permissions every chat page needs. + + Pinned and unpinned are split here rather than in the template so the + picker's optgroups stay a plain loop. + """ + models = chat_service.available_models(db, user) + current = next((m for m in models if m.model_id == chat.model_id), None) if chat else None + pinned = [m for m in models if m.pinned] + return { + "models": models, + "pinned_models": pinned, + # Excludes the pinned ones: they already have their own optgroup, and + # listing a model twice gives the + + {{ definition.label }} + {{ definition.description }} + + + {% endfor %} + + {% endfor %} + + + + +

+ Groups {{ groups|length }} +

+ +
+
+ + +
+
+ +{% if not groups %} +
+ {{ icon("users", "empty__mark") }} +

+ No groups yet. Create one to grant extra permissions, or to restrict a model + to a subset of users. +

+
+{% endif %} + +{% for group in groups %} +
+
+
+ {{ group.name }} + + {{ group.users|length }} member{{ '' if group.users|length == 1 else 's' }}, + {{ group.models|length }} model{{ '' if group.models|length == 1 else 's' }} + +
+ +
+ + +
+ +
+ + +
+ +
+ Grants +

+ Anything already in the baseline stays on regardless — these only add. +

+ {% for section_name, defs in permission_groups.items() %} + {% for definition in defs %} + + {% endfor %} + {% endfor %} +
+ +
+ Members + {% if users %} +
+ {% for account in users %} + + {% endfor %} +
+ {% else %} +

No users yet.

+ {% endif %} +
+ +
+ Model access +

+ Models marked “available to everyone” are reachable regardless. These + grant access to the restricted ones. +

+ {% if models %} +
+ {% for model in models %} + + {% endfor %} +
+ {% else %} +

No models yet.

+ {% endif %} +
+ + +
+ + +
+{% endfor %} +{% endblock %} diff --git a/src/lembas/web/templates/admin/models.html b/src/lembas/web/templates/admin/models.html index 49d7d59..cb9c22c 100644 --- a/src/lembas/web/templates/admin/models.html +++ b/src/lembas/web/templates/admin/models.html @@ -1,5 +1,5 @@ {% extends "admin/_layout.html" %} -{% from "_macros.html" import icon %} +{% from "_macros.html" import icon, model_avatar %} {% set section = "models" %} {% block title %}Models - LLeMbas{% endblock %} @@ -7,45 +7,186 @@ {% block admin_content %}

- Every model discovered on each connection. Disable the ones you do not want - cluttering the chat picker — nothing is deleted, and re-running - “Test & refresh” will not bring a disabled model back on. + Every model discovered across your connections. The order here is the order + users see. Pinned models are offered first, and the default is what a new chat + starts with.

-{% for connection in connections %} -
-

- {{ connection.name }} - {{ connection.models|length }} - {% if not connection.enabled %}connection disabled{% endif %} -

+{% if saved %} +
{{ icon("check", "icon--sm") }} {{ saved }}
+{% endif %} - {% if not connection.models %} -

- No models loaded. Run “Test & refresh” on - the connections page. -

- {% else %} -
    - {% for model in connection.models %} -
  • - {{ model.model_id }} -
    - -
    -
  • - {% endfor %} -
- {% endif %} -
-{% else %} +{% if not models %}
{{ icon("server", "empty__mark") }}

- No connections yet. Add one to load models. + No models yet. Add a connection and run + “Test & refresh”.

+{% else %} + +
+ With selected: + + + + + +
+ {% for model in models %} +
+ + + {{ model_avatar(model, cls="model-row__avatar") }} + +
+
+ {{ model.label }} + {% if model.model_id == default_model %} + {{ icon("star", "icon--sm") }} default + {% endif %} + {% if model.pinned %}pinned{% endif %} + {% if not model.enabled %}disabled{% endif %} + {% if not model.public %} + {{ model.groups|length }} group{{ '' if model.groups|length == 1 else 's' }} + {% endif %} + {% for name, on in (model.capabilities_json or {}).items() %} + {% if on %}{{ name }}{% endif %} + {% endfor %} +
+ {{ model.model_id }} + via {{ model.connection.name }} +
+ +
+ + + Edit +
+
+ {% endfor %} +
+
+ +

Model settings

+ +{% for model in models %} +
+
+
+ {{ model_avatar(model, cls="model-row__avatar") }} +
+ {{ model.label }} +
{{ model.model_id }}
+
+
+ {% if model.model_id != default_model %} +
+ +
+ {% endif %} +
+ +
+
+ + +

Shown instead of the raw model id. Leave empty to use the id.

+
+ +
+ + +
+ +
+ Capabilities +
+ {% for name in capabilities %} + + {% endfor %} +
+

+ Endpoints rarely advertise these reliably, so they are your call. + reasoning shows the thinking block; + vision and tools are used by features + not built yet. +

+
+ +
+
+ + +
+
+ +
+ +

+ Uncheck to restrict this model to specific groups. Administrators always + have access. +

+ {% if groups %} +
+ {% for group in groups %} + + {% endfor %} +
+ {% else %} +

+ No groups yet — create one to restrict access. +

+ {% endif %} +
+ + +
+ + +
{% endfor %} +{% endif %} {% endblock %} diff --git a/src/lembas/web/templates/admin/users.html b/src/lembas/web/templates/admin/users.html new file mode 100644 index 0000000..6928aa9 --- /dev/null +++ b/src/lembas/web/templates/admin/users.html @@ -0,0 +1,161 @@ +{% extends "admin/_layout.html" %} +{% from "_macros.html" import icon %} +{% set section = "users" %} + +{% block title %}Users - LLeMbas{% endblock %} +{% block heading %}Users{% endblock %} + +{% block admin_content %} +

+ Everyone with an account on this instance. Administrators bypass every + permission; ordinary users get the baseline permissions plus whatever their + groups add. +

+ +{% if saved %} +
{{ icon("check", "icon--sm") }} {{ saved }}
+{% endif %} + +
+ + + {% if q %}Clear{% endif %} +
+ +
+ Add a user +
+
+ + +
+
+ + +
+
+ + +

+ At least 8 characters. Tell them to change it — you will know it otherwise. +

+
+
+ + +
+ +
+
+ +

+ Accounts {{ users|length }} +

+ +{% for account in users %} +
+
+
+
+ + {{ account.name }} + {{ account.email }} + {% if account.is_admin %}admin{% endif %} + {% if not account.active %}deactivated{% endif %} + {% if account.id == user.id %}you{% endif %} +
+ + {% if account.last_login_at %} + last seen {{ account.last_login_at.strftime("%Y-%m-%d %H:%M") }} + {% else %} + never signed in + {% endif %} + +
+ +
+ + +
+ +
+ + +

+ admin can do everything, including this page. + user is an ordinary account. + pending cannot sign in until promoted. +

+
+ +
+ +

+ Deactivating signs them out everywhere immediately, rather than waiting + for their session to expire. +

+
+ + {% if groups %} +
+ Groups +
+ {% for group in groups %} + + {% endfor %} +
+
+ {% endif %} + + {% if account.is_admin and admin_count <= 1 %} +
+ {{ icon("warning", "alert__icon") }} + + The only administrator. Promote someone else before demoting or + deactivating this account — an instance with no admin can only be + recovered with lembas create-admin. + +
+ {% endif %} + + +
+ + +
+{% endfor %} +{% endblock %} diff --git a/src/lembas/web/templates/chat/_message.html b/src/lembas/web/templates/chat/_message.html index 51e5fd8..a9f8673 100644 --- a/src/lembas/web/templates/chat/_message.html +++ b/src/lembas/web/templates/chat/_message.html @@ -39,6 +39,19 @@ {% if streaming %} + {# Reasoning arrives before the answer, so this block sits above it. It + starts open (watching a model think is the point) and the :has() rule + in chat.css hides the whole thing while it is still empty, so models + that emit no reasoning never show an empty box. #} +
+ + {{ icon("sparkle", "icon--sm reasoning__icon") }} + Thinking + {{ icon("chevron-down", "icon--sm reasoning__chevron") }} + +
+
+ {# Tokens are appended here as they arrive. The cursor is a CSS pseudo-element on the empty parent, so it disappears by itself once the first token lands. #} @@ -47,6 +60,25 @@
+ {% elif message.reasoning and not message.error %} + {# Collapsed once finished: the answer is what the reader came for, and + the thinking is there if they want to audit it. #} +
+ + {{ icon("sparkle", "icon--sm reasoning__icon") }} + + {% if message.reasoning_ms %} + Thought for {{ message.reasoning_ms | duration }} + {% else %} + Reasoning + {% endif %} + + {{ icon("chevron-down", "icon--sm reasoning__chevron") }} + +
{{ message.reasoning }}
+
+
{{ body_html|safe }}
+ {% elif message.error %} +
+

Default model

+ {% if models %} +
+
+ +

What a new chat starts with. Existing chats keep their model.

+
+ +
+ {% else %} +

No models are available to you yet.

+ {% endif %} +
+ +
+

Permissions

+

+ {% if user.is_admin %} + You are an administrator, so every permission applies. + {% else %} + What this account may do, from the instance baseline plus your groups. + {% endif %} +

+
+ {% for key, granted in can.items() %} + + {{ key }}{{ '' if granted else ' — no' }} + + {% endfor %} +
+ {% if user.groups %} +

+ Groups: {% for group in user.groups %}{{ group.name }}{% if not loop.last %}, {% endif %}{% endfor %} +

+ {% endif %} +
+

Appearance

diff --git a/src/lembas/web/templating.py b/src/lembas/web/templating.py index 336cb6a..01481c3 100644 --- a/src/lembas/web/templating.py +++ b/src/lembas/web/templating.py @@ -11,6 +11,7 @@ from fastapi.templating import Jinja2Templates from lembas import __version__ from lembas.config import settings from lembas.db.models import User +from lembas.services.reasoning import format_duration TEMPLATE_DIR = Path(__file__).parent / "templates" STATIC_DIR = Path(__file__).parent / "static" @@ -19,6 +20,25 @@ templates = Jinja2Templates(directory=str(TEMPLATE_DIR)) templates.env.trim_blocks = True templates.env.lstrip_blocks = True +# {{ message.reasoning_ms | duration }} -> "8 seconds" +templates.env.filters["duration"] = format_duration + + +def stable_hue(value: str) -> int: + """A deterministic 0-359 hue for a string. + + Used for generated model avatars so each model gets its own colour without + anyone choosing one, and the same model looks the same on every page and + after every restart. Python's hash() is salted per process, hence md5. + """ + import hashlib + + digest = hashlib.md5(value.encode("utf-8"), usedforsecurity=False).digest() + return int.from_bytes(digest[:2], "big") % 360 + + +templates.env.filters["stable_hue"] = stable_hue + def resolve_theme(user: User | None) -> str: """Theme to render with on the server. diff --git a/tests/test_permissions.py b/tests/test_permissions.py new file mode 100644 index 0000000..9dd2108 --- /dev/null +++ b/tests/test_permissions.py @@ -0,0 +1,363 @@ +"""Groups, permissions and model access control.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from lembas.db.models import Chat, Connection, Group, Model, User +from lembas.security import permissions +from lembas.services import chat as chat_service +from lembas.services import settings_store +from lembas.services.crypto import encrypt + + +@pytest.fixture +def admin(client: TestClient, registered) -> None: + """The registered fixture already makes an administrator.""" + return None + + +@pytest.fixture +def plain_user(client: TestClient, db, registered) -> User: + """A second, non-admin account. Leaves the client signed in as them.""" + client.post("/auth/logout", follow_redirects=False) + client.post( + "/auth/register", + data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"}, + follow_redirects=False, + ) + return db.scalar(select(User).where(User.email == "sam@shire.test")) + + +def _connection(db) -> Connection: + connection = Connection( + name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("") + ) + db.add(connection) + db.commit() + return connection + + +def _model(db, model_id: str, **kwargs) -> Model: + model = Model(connection_id=_connection(db).id, model_id=model_id, **kwargs) + db.add(model) + db.commit() + return model + + +# --- Permission resolution --------------------------------------------------- +def test_admins_get_everything(db, registered): + admin_user = db.scalar(select(User).where(User.role == "admin")) + assert all(permissions.resolve(db, admin_user).values()) + + +def test_signed_out_gets_nothing(db): + assert not any(permissions.resolve(db, None).values()) + + +def test_plain_user_gets_the_baseline(db, plain_user): + resolved = permissions.resolve(db, plain_user) + assert resolved["chat.create"] is True + # Off in the baseline by default. + assert resolved["chat.params"] is False + + +def test_a_group_widens_permissions(db, plain_user): + group = Group(name="Power users", permissions_json={"chat.params": True}) + group.users = [plain_user] + db.add(group) + db.commit() + assert permissions.resolve(db, plain_user)["chat.params"] is True + + +def test_groups_union_rather_than_override(db, plain_user): + """A second group can only ever add. Absent means 'no opinion', not 'deny'.""" + db.add_all( + [ + Group(name="A", permissions_json={"chat.params": True}, users=[plain_user]), + Group(name="B", permissions_json={}, users=[plain_user]), + ] + ) + db.commit() + assert permissions.resolve(db, plain_user)["chat.params"] is True + + +def test_baseline_can_be_narrowed_instance_wide(db, plain_user): + settings_store.update(db, {"default_permissions": {"chat.create": False}}) + assert permissions.resolve(db, plain_user)["chat.create"] is False + + +def test_a_group_can_grant_back_what_the_baseline_removed(db, plain_user): + settings_store.update(db, {"default_permissions": {"chat.create": False}}) + db.add(Group(name="Writers", permissions_json={"chat.create": True}, users=[plain_user])) + db.commit() + assert permissions.resolve(db, plain_user)["chat.create"] is True + + +# --- Enforcement through the API --------------------------------------------- +def test_creating_a_chat_is_refused_without_permission(client: TestClient, db, plain_user): + settings_store.update(db, {"default_permissions": {"chat.create": False}}) + assert client.post("/api/chats").status_code == 403 + assert db.scalar(select(Chat)) is None + + +def test_folder_routes_are_refused_without_permission(client: TestClient, db, plain_user): + settings_store.update(db, {"default_permissions": {"folder.manage": False}}) + assert client.post("/api/folders", data={"name": "Nope"}).status_code == 403 + + +def test_changing_sampling_is_refused_without_permission(client: TestClient, db, plain_user): + _model(db, "test-model") + chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1] + response = client.patch(f"/api/chats/{chat_id}", data={"temperature": "0.9"}) + assert response.status_code == 403 + + +def test_sampling_is_allowed_once_a_group_grants_it(client: TestClient, db, plain_user): + _model(db, "test-model") + db.add(Group(name="Tuners", permissions_json={"chat.params": True}, users=[plain_user])) + db.commit() + + chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1] + assert client.patch(f"/api/chats/{chat_id}", data={"temperature": "0.9"}).status_code == 204 + + chat = db.get(Chat, chat_id) + db.refresh(chat) + assert chat.params_json["temperature"] == 0.9 + + +@pytest.mark.parametrize( + ("field", "value"), + [("temperature", "5"), ("top_p", "-1"), ("max_tokens", "0"), ("temperature", "abc")], +) +def test_out_of_range_parameters_are_dropped_not_clamped( + client: TestClient, db, registered, field, value +): + """Silently changing what someone typed is worse than ignoring it.""" + _model(db, "test-model") + chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1] + client.patch(f"/api/chats/{chat_id}", data={field: value}) + + chat = db.get(Chat, chat_id) + db.refresh(chat) + assert field not in (chat.params_json or {}) + + +def test_an_empty_parameter_clears_it(client: TestClient, db, registered): + _model(db, "test-model") + chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1] + client.patch(f"/api/chats/{chat_id}", data={"temperature": "0.7"}) + client.patch(f"/api/chats/{chat_id}", data={"temperature": ""}) + + chat = db.get(Chat, chat_id) + db.refresh(chat) + assert chat.params_json["temperature"] is None + + +# --- Model access ------------------------------------------------------------ +def test_public_models_are_visible_to_everyone(db, plain_user): + _model(db, "open-model", public=True) + assert [m.model_id for m in permissions.models_visible_to(db, plain_user)] == ["open-model"] + + +def test_restricted_models_are_hidden_without_a_group(db, plain_user): + _model(db, "secret-model", public=False) + assert permissions.models_visible_to(db, plain_user) == [] + + +def test_a_group_grants_access_to_a_restricted_model(db, plain_user): + model = _model(db, "secret-model", public=False) + group = Group(name="Insiders", users=[plain_user], models=[model]) + db.add(group) + db.commit() + assert [m.model_id for m in permissions.models_visible_to(db, plain_user)] == ["secret-model"] + + +def test_admins_see_restricted_models(db, registered): + _model(db, "secret-model", public=False) + admin_user = db.scalar(select(User).where(User.role == "admin")) + assert [m.model_id for m in permissions.models_visible_to(db, admin_user)] == ["secret-model"] + + +def test_disabled_models_are_hidden_from_everyone(db, registered): + _model(db, "off-model", enabled=False) + admin_user = db.scalar(select(User).where(User.role == "admin")) + assert permissions.models_visible_to(db, admin_user) == [] + + +def test_switching_to_an_inaccessible_model_is_refused(client: TestClient, db, plain_user): + """The picker is not the security boundary; a crafted request must fail.""" + _model(db, "open-model", public=True) + _model(db, "secret-model", public=False) + + chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1] + response = client.patch(f"/api/chats/{chat_id}", data={"model_id": "secret-model"}) + assert response.status_code == 403 + + chat = db.get(Chat, chat_id) + db.refresh(chat) + assert chat.model_id == "open-model" + + +def test_model_select_permission_is_required_to_switch(client: TestClient, db, plain_user): + _model(db, "a-model", public=True) + _model(db, "b-model", public=True) + settings_store.update(db, {"default_permissions": {"chat.model_select": False}}) + + chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1] + assert client.patch(f"/api/chats/{chat_id}", data={"model_id": "b-model"}).status_code == 403 + + +# --- Ordering and defaults --------------------------------------------------- +def test_pinned_models_sort_first(db, registered): + connection = _connection(db) + db.add_all( + [ + Model(connection_id=connection.id, model_id="ordinary", position=0), + Model(connection_id=connection.id, model_id="favourite", position=9, pinned=True), + ] + ) + db.commit() + admin_user = db.scalar(select(User).where(User.role == "admin")) + assert [m.model_id for m in chat_service.available_models(db, admin_user)] == [ + "favourite", + "ordinary", + ] + + +def test_position_decides_order_among_unpinned(db, registered): + connection = _connection(db) + db.add_all( + [ + Model(connection_id=connection.id, model_id="second", position=1), + Model(connection_id=connection.id, model_id="first", position=0), + ] + ) + db.commit() + admin_user = db.scalar(select(User).where(User.role == "admin")) + assert [m.model_id for m in chat_service.available_models(db, admin_user)] == [ + "first", + "second", + ] + + +def test_instance_default_model_is_used_for_new_chats(client: TestClient, db, registered): + connection = _connection(db) + db.add_all( + [ + Model(connection_id=connection.id, model_id="first", position=0), + Model(connection_id=connection.id, model_id="chosen", position=5), + ] + ) + db.commit() + settings_store.update(db, {"default_model": "chosen"}) + + client.post("/api/chats") + assert db.scalar(select(Chat)).model_id == "chosen" + + +def test_a_users_own_default_beats_the_instance_default(client: TestClient, db, plain_user): + connection = _connection(db) + db.add_all( + [ + Model(connection_id=connection.id, model_id="instance-pick", position=0), + Model(connection_id=connection.id, model_id="my-pick", position=5), + ] + ) + db.commit() + settings_store.update(db, {"default_model": "instance-pick"}) + client.post("/api/preferences/default-model", data={"model_id": "my-pick"}) + + client.post("/api/chats") + assert db.scalar(select(Chat)).model_id == "my-pick" + + +def test_an_unreachable_default_falls_through(db, plain_user): + """A default the user has lost access to must not produce a dead chat.""" + connection = _connection(db) + db.add_all( + [ + Model(connection_id=connection.id, model_id="allowed", position=0, public=True), + Model(connection_id=connection.id, model_id="gone", position=1, public=False), + ] + ) + db.commit() + settings_store.update(db, {"default_model": "gone"}) + assert chat_service.default_model(db, plain_user)[0] == "allowed" + + +def test_choosing_an_inaccessible_default_is_refused(client: TestClient, db, plain_user): + _model(db, "secret-model", public=False) + response = client.post( + "/api/preferences/default-model", data={"model_id": "secret-model"}, follow_redirects=False + ) + assert "error=" in response.headers["location"] + db.refresh(plain_user) + assert "default_model" not in (plain_user.settings_json or {}) + + +# --- Admin guards ------------------------------------------------------------ +def test_ordinary_users_cannot_reach_user_administration(client: TestClient, db, plain_user): + for path in ("/admin/users", "/admin/groups", "/admin/models"): + assert client.get(path, follow_redirects=False).status_code == 403, path + + +def test_the_last_administrator_cannot_be_demoted(client: TestClient, db, registered): + admin_user = db.scalar(select(User).where(User.role == "admin")) + response = client.post( + f"/admin/users/{admin_user.id}", + data={"name": admin_user.name, "role": "user", "active": "true"}, + follow_redirects=False, + ) + assert "only+administrator" in response.headers["location"] + + db.refresh(admin_user) + assert admin_user.role == "admin" + + +def test_you_cannot_delete_your_own_account(client: TestClient, db, registered): + admin_user = db.scalar(select(User).where(User.role == "admin")) + response = client.post( + f"/admin/users/{admin_user.id}/delete", follow_redirects=False + ) + assert "your+own+account" in response.headers["location"] + assert db.get(User, admin_user.id) is not None + + +def test_deactivating_a_user_revokes_their_sessions(client: TestClient, db, registered): + """Otherwise the change only lands when their cookie happens to expire.""" + other = TestClient(client.app) + other.post( + "/auth/register", + data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"}, + follow_redirects=False, + ) + assert other.get("/chat", follow_redirects=False).status_code == 200 + + target = db.scalar(select(User).where(User.email == "sam@shire.test")) + client.post( + f"/admin/users/{target.id}", + data={"name": target.name, "role": "user"}, # `active` absent means off + follow_redirects=False, + ) + + assert other.get("/chat", follow_redirects=False).status_code == 303 + + +def test_a_pinned_model_appears_once_in_the_picker(client: TestClient, db, registered): + """Two options with the same value, both selected, is not a picker.""" + connection = _connection(db) + db.add_all( + [ + Model(connection_id=connection.id, model_id="favourite", pinned=True, position=0), + Model(connection_id=connection.id, model_id="ordinary", position=1), + ] + ) + db.commit() + + chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1] + page = client.get(f"/chat/{chat_id}").text + assert page.count('