Exporting workspace models loaded every model row, built a full response object with its owner for each, and only then dropped the ones the caller may not see. On a large model table that made the export endpoint slow in proportion to models the user cannot even access.
The owner-or-grant check now happens in the query itself, reusing the permission filter this file already applies to the paginated list endpoint, so only visible rows are ever hydrated. The by-user wrapper had one caller left and is gone with it.
Measured with 500 workspace models of which 3 are visible to the caller: 5 queries and ~12.7 ms before, 4 queries and ~2.8 ms after. The resulting set is unchanged for owner, public, direct-user, group and multi-grant entries, and base model entries stay excluded as before.
Checking whether a user may reach a file loaded and validated every workspace model that user can access, then scanned each model's knowledge list in Python for one file id. Folder listings run that check once per file, so opening a folder of twenty files rebuilt the whole accessible-model set twenty times, and the same check sits on every retrieval and download path.
The lookup now runs the other way round: the database returns the models that attach the file, and only those are access-checked. The text match on the metadata column is a prefilter and the knowledge entries still decide, so a file id that merely appears in a description grants nothing; file ids are server-generated uuids, so the match can only be too wide, never too narrow.
Measured with 500 accessible workspace models: a single check drops from 9 queries and ~20 ms to 6 and ~2.6 ms, and a twenty-file folder listing from 180 queries and ~680 ms to 120 and ~56 ms. A 72-case matrix over owner, public, direct-user and group grants, for both read and write, returns exactly what it returned before, and write still requires the model owner to own the file. The check also no longer writes to the database while answering a read-only question.
Three searches LIKE against cast(json_col AS text), which means they have to match
bytes a JSON encoder wrote. Encoders disagree on non-ASCII: stdlib escapes it to
\uXXXX, orjson writes it raw. Which one produced a row depends on the codec in force
when it was written, so any single pattern finds only half the table.
models.py hard-codes the stdlib spelling, with a comment asserting SQLite stores
JSON via json.dumps(ensure_ascii=True). Model.meta is a JSONField, which has
serialised through JSONCodec since ENABLE_ORJSON was introduced, so on that setting
it stores raw UTF-8 and the escaped pattern matches nothing: non-ASCII workspace
model tag search is broken today. prompts.py and automations.py hard-code the
opposite spelling and miss rows written the other way.
json_text_variants returns both spellings a string can take inside serialised JSON,
collapsing to one for ASCII, and the three call sites OR over them. Rows written
under either setting are now found under either setting, which also covers a
database holding a mix of the two.
Case handling is unchanged. models.py keeps matching non-ASCII tags case-sensitively
on SQLite, whose LOWER() is ASCII-only and would not fold the stored text the way
str.lower() folds the tag. ASCII tags collapse to a single variant and take exactly
the query they took before.
Verified on SQLite across every combination of codec-that-wrote-the-row and
codec-the-app-is-running, for an ASCII and a CJK tag, over all three call sites: 24
of 24 match, against 12 of 24 before. Quoting still bounds whole-tag matches, so
searching "weather" does not match a row tagged "weathervane".
Co-authored-by: Claude <noreply@anthropic.com>
POST /api/v1/models/sync only worked when every model in the payload was new. As soon as one id already existed, the whole call blew up and the endpoint still answered HTTP 200 with an empty list, so nothing was updated and well-behaved clients saw a success. Only a first-ever sync into an empty catalogue went through.
The update branch splatted the model dump (which already carries user_id and updated_at) and then passed both again as explicit keyword arguments, which is a duplicate-keyword TypeError before SQLAlchemy ever sees it. The insert branch right below merged the same values into a dict first, so it never collided.
Fixed by building that dict once and using it for both branches, matching how sync_functions already does it. Left the broad exception handler alone: it is the reason the failure was silent, but changing the error contract of sync_models is a separate call.
Fixes#28033
Both session factories run with expire_on_commit=False, so ORM objects keep their attribute values after commit. Every session.refresh issued right after a commit therefore re-SELECTed a row whose values the session already held, including full chat JSON blobs and user settings, purely to overwrite identical data. Fifty such calls existed across the model layer, covering nearly every write path in the app (chat inserts, title updates, pin/archive toggles, user role and settings updates, tool, prompt, function, model, file, tag, feedback, memory, automation and grant writes).
All fifty are removed. The only refreshes with an actual job were the two update-then-reload paths in tools and skills, where a Core UPDATE statement bypasses the identity map; those now use session.get(..., populate_existing=True), which guarantees a fresh row in one SELECT whether or not the row was already present in the session (the previous code issued get plus refresh, two SELECTs, on the default configuration).
Benchmark (real SQLite DB, per write):
| write path | before | after |
| --- | --- | --- |
| chat title update, ~600 KB chat blob | 2.08 ms | 1.24 ms |
| user role update, small row | 1.21 ms | 0.68 ms |
On Postgres each removed refresh is additionally a network round trip. The chat-blob case also skips re-parsing the entire JSON document per write.
Functionally verified against a fresh database: user insert, role and settings updates, chat insert (including the server-default meta column, which is always provided client-side), title update and pin toggle, tool insert and the Core-update reload path, tag insert and the prompt insert flow that pins version_id after history creation all return correct values and persist correctly.
has_access_to_file runs for every non-owner file GET, per RAG file check and per shared-chat or model-attached file. Its final step called Models.get_models_by_user_id, which issued one grant query per non-owned workspace model, so a single file check on an instance with M workspace models cost M grant queries plus a group query, with the deny path always paying full price. Its collection_name step listed every knowledge base the user can access (itself one grant query per knowledge base) just to scan the list for one id. And get_accessible_folder_files repeated the whole pipeline per folder entry, refetching the caller's group memberships every time.
Three changes, all using parameters and helpers that already exist:
- Models.get_models_by_user_id resolves grants for all non-owned models in one get_accessible_resource_ids call and accepts prefetched user_group_ids.
- The collection_name check fetches the one referenced knowledge base and performs a single owner-or-grant check with the already-resolved group ids, preserving the write-requires-owner guard exactly (including its short-circuit before any grant query).
- get_accessible_folder_files resolves group ids once and threads them through every per-entry check.
Benchmark:
| metric | before | after |
| --- | --- | --- |
| filter loop CPU, 300 workspace models (queries stubbed) | 47 us | 19 us |
| grant queries per file-access check, M workspace models | M | 1 |
| group membership queries per folder listing, F files | F | 1 |
The stubbed CPU row understates the win: each removed query in the other two rows was a real database round trip.
Functionally verified with stubbed accessors: owned plus granted models are returned with owned ids excluded from the batch query; model-attached file access resolves through the batched path; the collection_name path does one KB fetch and one grant check with no full listing; a missing KB falls through; write access via a KB still requires the KB owner to own the file and short-circuits before the grant query; folder listings fetch groups exactly once.
The SVG-XSS hardening introduced in f5f4b5895 correctly rejects
data:image/svg+xml URIs on new input, but also caused a
pydantic_core.ValidationError when reading pre-existing models from
the database that had SVG data URIs stored as their profile images.
This ValidationError propagated unhandled through _to_model_model and
get_all_models, crashing the entire /api/models endpoint with HTTP 500
and leaving users with no models available in the UI.
Fix:
- Wrap validate_profile_image_url() in a try/except ValueError inside
ModelMeta.check_profile_image_url. Legacy entries are cleared to None
with a warning log instead of raising — the /model/profile/image API
endpoint already falls back to /static/favicon.png when the value is
empty.
- Default ModelMeta.profile_image_url to None instead of hardcoding
/static/favicon.png, since the serving endpoint handles the fallback.
- Add a per-model try/except in ModelsTable.get_all_models so that any
future unexpected validation failure on a single record skips that
model with an error log rather than aborting the entire list.
ModelMeta.profile_image_url now runs validate_profile_image_url, rejecting SVG/script data URIs (matching UserUpdateForm and ChannelWebhookForm). The /model/profile/image endpoint enforces the PROFILE_IMAGE_ALLOWED_MIME_TYPES allowlist and sets X-Content-Type-Options: nosniff, so an SVG data URI can no longer be served inline on-origin. Closes the fourth profile-image XSS sink missed by the user and webhook fixes.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This fix restores and extends the username/email search functionality across workspace pages that was originally added in PR #14002. The issue was that:
1. The backend search functions for Models and Knowledge only searched `User.name` and `User.email`, but not `User.username`
2. The Functions admin page lacked user search entirely
Changes made:
Added User.username to backend search conditions for Models and Knowledge pages
Added complete user search (name, email, username) to the Functions admin page client-side filter
* feat: Add read-only access support for Models
- Backend: Add write_access field to ModelAccessResponse
- Backend: Update /models/list to return ModelAccessListResponse
- Frontend: Display Read Only badge in Models list
- Frontend: Disable inputs and save button when no write access
- Frontend: Hide action buttons for read-only models
* fix: Handle ModelAccessListResponse format in getModels API
- Backend returns {items, total} instead of {data}
- Update getModels API to handle both formats for backward compatibility
* fix: Show read-only shared models in workspace list
- Backend: Change search_models permission from 'write' to 'read' to include shared models
- Backend: Keep user_id filter to only show owned/shared models (not all public)
- Frontend: Handle ModelAccessListResponse format in getModels API
* fix: Align Read Only badge inline with model name
* fix: Correct badge placement and fix syntax error
* fix: Resolve badge truncation in Models list
- Add w-full to flex container for proper spacing
- Wrap Badge in div to prevent truncation
- Match Knowledge.svelte badge pattern
* fix: Align Read Only badge with Knowledge.svelte pattern
- Match Knowledge.svelte structure for badge placement
- Actions only show when write_access or admin
- Remove w-full from container to prevent right-overflow
* fix: Return write_access from getModelById endpoint
fix: Return write_access from getModelById endpoint
- Use ModelAccessResponse instead of raw dict
- Remove inefficient getModels call in edit page
* revert
* fix
* fix
* fix
- Pre-fetch user group IDs in get_*_by_user_id methods across models layer
- Pass user_group_ids to has_access to avoid repeated group queries
- Reduce query count from 1+N to 1+1 pattern for access control validation
- Apply consistent optimization across knowledge, models, notes, prompts, and tools
Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
- Replace individual user queries with batch fetching
- Use single query to fetch all required users at once
- Implement O(1) user lookup with dictionary mapping
- Reduce query count from 1+N to 1+1 pattern for models with base_model_id
Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>