fix: hide embedding/stt/tts aliases from the OWUI chat picker (#772) - #776
Conversation
Open WebUI's chat model dropdown listed hive-embedding-default, hive-stt
and hive-tts as selectable chat models. Picking one produced a broken
conversation because none of them serve chat completions.
Filtering these out of /v1/models would violate the OpenAI contract
(embedding/audio ids are supposed to be listed there, and edge-api's
support-matrix marks this supported_now). Marking them restricted in
tenant_model_visibility would block invocation entirely, taking out RAG
and voice for the shim account that calls them.
Instead, extend the existing OWUI access_control sync (syncOWUI) so any
alias whose capability_badges intersect {embeddings, stt, tts, voice}
always gets the placeholder lockout group, independent of its
Visibility class or any tenant_model_visibility grant. The predicate is
exclusion-based (does a badge match a known non-chat tag), not
inclusion-based on a "chat" badge, because hive-auto is a legitimate
chat-selectable alias with no explicit "chat" badge
(["auto","fallback","preview"]).
That sync only ever ran from the admin PUT/DELETE visibility mutation
path, which the three aliases above never went through — they were
seeded directly by SQL migrations. A new boot-time
VisibilityHandler.ReconcileOWUISync walks every alias once at startup
and applies the same sync, so the fix actually takes effect on an
existing box and re-applies itself after an Open WebUI image bump
resets access_control on its own model rows.
capability_badges is freeform jsonb with no CHECK constraint and
model_aliases has no dedicated modality column, so this is a bounded
shortcut (see the ponytail comment on nonChatModalityBadges in
http.go); the real fix is a modality or is_chat_model column.
Tests added test-first (RED confirmed as a compile failure against the
pre-fix code, then GREEN): TestSyncOWUI_PublicNonChatAlias_UsesPlaceholder,
TestIsNonChatModality (table-driven, includes the hive-auto exclusion
case), TestReconcileOWUISync_MigrationSeededNonChatAlias_GetsPlaceholder
plus nil-OWUI and repository-error cases. TestSyncOWUI_PublicAlias_SkipsSync
was narrowed to a genuinely chat-shaped alias (real chat badges instead of
an empty CapabilityBadges slice), since it previously passed without
actually distinguishing a chat alias from a non-chat one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Warning Review limit reached
Next review available in: 15 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (7)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…eads (#792) (#814) Fixes #792. Supersedes the mechanism in #776, which is left in place but is not what fixes this. ## The diagnosis, confirmed against the pinned image #776 hid `hive-embedding-default`, `hive-stt` and `hive-tts` by writing Open WebUI's per-model `access_control`. It was reviewed, tested, merged and deployed, and it changed nothing. Two independent reasons, both read out of `ghcr.io/open-webui/open-webui:v0.10.2@sha256:9fcea9c…` rather than assumed: 1. `deploy/docker/docker-compose.yml:738` sets `BYPASS_MODEL_ACCESS_CONTROL: "true"`. `env.py:749` turns that into a module constant, and the picker's listing path, `main.py:842` in the `@app.get('/api/models')` handler, calls `get_filtered_models`, which begins `if (user.role == 'user' or ...) and not BYPASS_MODEL_ACCESS_CONTROL:` and otherwise `return models` unchanged (`utils/models.py:418-472`). The flag switches the filter off for every role, so the `access_control` values #776 writes are never read. 2. Even with that flag off, `get_filtered_models` exempts administrators whenever `BYPASS_ADMIN_ACCESS_CONTROL` is set, and that defaults to true (`config.py:2029`), while the `#457` tenant-role patch promotes every tenant owner to an Open WebUI administrator. Every human on the demo box is an admin. So the mechanism could not fire on either count. Note that every test shipped with #776 still passed, because they all asserted that the correct values were **written** and none asserted that anything was **read**. ## What actually depends on the bypass, measured The tempting fix is to drop the flag. #792 flags document RAG and text-to-speech as the risk. That turned out to be wrong in both directions, and the measurement is what decided this PR's design. An isolated harness (the pinned image, a pgvector container, and a stub gateway serving exactly the six catalog aliases and answering chat, embeddings, `audio/speech` and `audio/transcriptions`), with Open WebUI's environment mirroring the `open-webui` service block, run against two personas: an Open WebUI `admin` (what a tenant owner actually is) and an Open WebUI `user`. | | admin persona | member persona | | --- | --- | --- | | Bypass on (as deployed) | picker lists all 6, chat 200 | picker lists all 6, chat 200 | | Bypass removed | picker **still lists all 6**, chat 200 | picker **empty**, chat **400 "Model not found"** | Document RAG upload, ingest and query, TTS and STT all returned 200 for both personas in both configurations. None of them consults Open WebUI's model registry: `routers/audio.py` contains no access-control call at all, and the RAG embedder issues a direct HTTP request to `RAG_OPENAI_API_BASE_URL`. So the bypass does not protect RAG or TTS. It protects chat for non-admin members, and removing it would not have hidden a single alias from the people who use the demo. Full runs in `docs/proof/issue-792/probe-*.json`. ## The fix Filter the listing the picker reads, and nothing else. A build-time splice into `main.py`'s `/api/models` handler, the same posture as this Dockerfile's other Open WebUI patches. It runs on the response: `request.app.state.MODELS` is untouched, so chat, RAG embeddings and text-to-speech still resolve every alias. `GET /v1/models` on the API origin is not touched at all, there are no Go changes in this PR, and that endpoint stays an OpenAI-contract surface that `support-matrix.json` marks supported and that direct API clients depend on. Two things are deliberate because of how #776 failed: - The inserted call is **unconditional**, and `assert_unconditional` in the patch fails the build if a future edit puts it behind a flag, a role check or an access-control branch. There is no conditional available here that this deployment does not already disable. - The transform lives in a `patch()` function rather than inline, so the guard can run the real thing against a checked-in verbatim excerpt of the pinned image's own handler (`pinned-main-excerpts.json`). PR CI never builds this image, which is exactly why that excerpt is committed, following the precedent `dump_bundle_excerpts.py` already sets. The hidden set is never hardcoded. It is `HIVE_PICKER_HIDDEN_MODEL_IDS` (compose owns it) unioned with whatever `RAG_EMBEDDING_MODEL`, `AUDIO_TTS_MODEL` and `AUDIO_STT_MODEL` name, so changing the admin-selected embedding alias does not need a second edit (D-001). Unset variables contribute nothing, so a deployment that sets none of them keeps upstream behaviour. ### Rejected - **Dropping `BYPASS_MODEL_ACCESS_CONTROL`.** Measured above: breaks chat for every member, hides nothing from admins. - **Anything access-control shaped**, including repairing #776. Inert by construction on this deployment. - **Filtering `/v1/models` in edge-api by shim identity.** `owui_unwrap.go` keeps `hasShimAuthorization` unexported precisely so no route branches on "is this the shim key", and `handleModels` documents that there is no exception for it. Both would have had to be reversed. - **Filtering `/v1/models` for everyone.** Violates the OpenAI contract; upstream OpenAI lists embedding and audio ids too. ## The guard `scripts/test_owui_model_picker_filter.py`, wired into `make test-scripts`, which the required `repo-policy-lints` check runs on every non-docs change. It is built to fail the way #776 would have failed. It asserts the filter reaches the image and lands on the response path between upstream's own filtering and the return, rather than asserting a value was written, and it asserts the coupling directly: while compose sets the bypass, a picker filter that does not depend on access control must be present and must run in the image. **Red, against the pre-fix image with the bypass on, which is the deployed world:** ``` OWUI chat model picker filter regression (1 failure(s)): run_live: the chat picker still lists ['hive-embedding-default', 'hive-stt', 'hive-tts'] (issue #792). The picker filter is not on the response path, or something is gating it. exit=1 ``` **Green, patched image, bypass still on, same harness, same command:** ``` live: gateway serves 6 models including all 3 non-chat aliases; picker lists 3 and none of them OWUI chat model picker filter: 10 checks passed exit=0 ``` The static half that CI runs goes red the same way with the Dockerfile's `RUN` line neutered and compose untouched, naming both the missing invocation and the bypass coupling. Transcripts in `docs/proof/issue-792/README.md`. ## Live proof Both halves, captured against a running stack, in one signed-in session. Picker before, signed in as the owner persona. All six, including the three that cannot serve a completion:  Picker after, same persona, same harness, patched image. Three chat aliases:  The gateway's own list in that same session, post-fix, still carrying all six:  And `/api/models`, the listing the picker reads, post-fix:  Probe C confirms the rest of the surface is unaffected with the fix in place and the bypass left on: chat 200, RAG ingest completed, RAG query returning a hit, TTS 200 and STT 200, for both personas. ## Test plan - [x] `make test-scripts` green, including the new guard - [x] New guard proved red against the pre-fix image and green against the patched one, same harness - [x] New guard proved red statically with the Dockerfile `RUN` line neutered - [x] Image builds; every patch assertion in `Dockerfile.open-webui` passes - [x] Picker shows only the three chat aliases, admin and member personas - [x] Gateway model list still returns all six in the same session - [x] Chat, document RAG ingest and query, TTS and STT all still work post-fix - [ ] Confirmed on the demo box after deploy, which this PR does not perform ## Notes for the reviewer The Open WebUI `AUDIO_*` settings are wired in the harness even though `docker-compose.yml` does not set them today. #792 named text-to-speech as a risk, and a risk that is not configured cannot be measured. That harness wiring is not part of this diff. #776's control-plane machinery is left untouched. It is inert on this deployment but harmless, and it becomes live if the bypass is ever turned off, which is a decision for whoever makes it. The guard makes that coupling explicit rather than silent. ## Rebased onto main, 2026-08-17 This PR sat open since 2026-08-09. A lot landed on `main` in that window, including Phase 20 provider-catalog waves and pricing corrections, so this branch was merged forward (`4e39de40`) and re-verified against current code rather than assumed still correct. Checked, not assumed: - The pinned image digest is unchanged (`ghcr.io/open-webui/open-webui:v0.10.2@sha256:9fcea9c…`). - `public.model_aliases` has not grown a new non-chat alias and none of the three (`hive-embedding-default`, `hive-stt`, `hive-tts`) were renamed; confirmed against every migration that touches the table, most recently `20260801_13_alias_price_unit.sql`. - `scripts/test_owui_model_picker_filter.py` still passes statically (10/10) against the checked-in `pinned-main-excerpts.json`. - `docker-compose.yml`'s `HIVE_PICKER_HIDDEN_MODEL_IDS` default and `RAG_EMBEDDING_MODEL`/`OWUI_RAG_EMBEDDING_ALIAS` wiring are unchanged and still name the same three aliases. One cleanup from the merge itself: `.wolf/buglog.jsonl`'s `merge=union` driver kept this branch's own append after merging `main` forward. Branch appends to that file are never allowed regardless of the merge driver (`.claude/rules/openwolf.md`); dropped it back to `main`'s content in a separate commit and moved the entry to **Buglog entry** below. ### Fresh A/B proof, same method as PR #909 The demo box still runs `main`, so nothing captured against it can show this fix. Two `Dockerfile.open-webui` images, same pinned digest, differing only in `owui-patches/`, run standalone against a stub gateway serving the real six-alias catalog shape. Full method and logs: `docs/proof/pr-814-rebase-verification-2026-08-17/README.md`. Built from `main` (no patch) — all six aliases in the dropdown:  Built from this branch — three chat aliases:  DOM read backing each screenshot (which of the six known ids actually rendered in the opened dropdown), not just the image: ``` main: ["hive-auto","hive-default","hive-fast","hive-embedding-default","hive-stt","hive-tts"] branch: ["hive-auto","hive-default","hive-fast"] ``` ## Is filtering the picker the right fix, or should these aliases not be in the chat-facing catalog at all? Asked directly, and the honest answer has two parts, because a design pass run in parallel on this same question reached a real finding that this PR's own "Rejected" section above already anticipated and rejected once. **The finding, verified against current code and correct as stated:** `public.model_aliases` carries no `modality`/`is_chat_model` column, `apps/control-plane/internal/catalog/repository.go`'s alias-listing queries (`ListPublicAliases`, the tenant-visibility query, `GetAlias`, `ListAllAliases`) never join `provider_capabilities`, and `apps/edge-api/cmd/server/main.go`'s `handleModels` (~line 774) serializes `snapshot.Models` verbatim. So yes: `GET /v1/models` on the API origin lists `hive-embedding-default`, `hive-stt` and `hive-tts` with nothing marking them as non-chat, for every caller, chat-scoped or not. **Where the conclusion drawn from that finding doesn't hold:** the proposal was to filter `/v1/models` itself so it "returns only chat-capable models for a chat-scoped request." This PR's own Rejected section already considered and rejected the unscoped version of that ("Filtering `/v1/models` for everyone. Violates the OpenAI contract; upstream OpenAI lists embedding and audio ids too.") for a reason that's still true: a real OpenAI `/v1/models` response is undifferentiated too, and a direct API client that wants to call `POST /v1/embeddings` or `POST /v1/audio/speech` with a Hive alias needs to discover it via `GET /v1/models` first, the same way it would against upstream OpenAI. Stripping the three aliases from the endpoint unconditionally would break that discovery path for every non-chat SDK integrator to fix a problem that, for them, does not exist: nobody calling `/v1/embeddings` is confused by seeing `hive-embedding-default` in the model list. **So: keep #814's picker filter. Do not expand this PR to touch `/v1/models`.** The client-side fix stays the answer to the owner's actual complaint (Open WebUI's chat picker specifically), and it is not "defense in depth" for a server fix that doesn't exist — until something reconfigures Open WebUI to consume a genuinely chat-scoped listing, this patch is the only thing making the picker correct. There is a real, narrower question worth designing separately: a **chat-scoped** variant of the listing, for a caller that opts in to "only models I can send a chat completion to" (which Open WebUI's picker could then consume server-side instead of client-side). That needs its own signaling mechanism (query param, header, or a distinct endpoint) so the unparameterized `GET /v1/models` keeps its current, contract-correct shape. No migration is needed to build it either way: the capability data already exists twice over and is unused by the listing endpoint today — `apps/control-plane/internal/catalog/http.go`'s tested `isNonChatModality` over `capability_badges` (currently wired only to the inert, bypassed #776 OWUI `access_control` sync), and `provider_capabilities.supports_chat_completions` / `supports_embeddings` / `supports_tts` / `supports_stt`, already joined per-route in `listRouteSnapshots` for routing decisions but never surfaced to `handleModels`. Filed as #931 rather than folded into this PR, since it's a genuine design question (is there real demand for it beyond this one picker?) and, if built, its own reviewed diff. ## Buglog entry Carried here per branch policy (never appended to `.wolf/buglog.jsonl` on a feature branch). To be appended to `main` in a separate buglog-only PR once this merges: ```json {"id":"bug-msmbcjgc-8012bd","timestamp":"2026-08-09T21:27:12.251Z","related_bugs":[],"occurrences":1,"last_seen":"2026-08-09T21:27:12.251Z","error_message":"Open WebUI chat model picker still listed hive-embedding-default, hive-stt and hive-tts after PR #776 merged and deployed (issue #792)","root_cause":"#776 hid them by writing Open WebUI per-model access_control, but deploy/docker/docker-compose.yml sets BYPASS_MODEL_ACCESS_CONTROL true, which makes main.py skip get_filtered_models for every role in the pinned v0.10.2 image. Even with that flag off, get_filtered_models exempts admins whenever BYPASS_ADMIN_ACCESS_CONTROL is set, and it defaults to true while this deployment promotes every tenant owner to an Open WebUI admin. The mechanism could never fire, and every test shipped with it asserted that values were written rather than read.","fix":"filter the /api/models response instead, via a build-time patch (deploy/docker/owui-patches/apply_model_picker_patch.py plus hive_model_picker.py). Unconditional, env-driven, applied after upstream own filtering and before the return, leaving request.app.state.MODELS untouched so chat, RAG embeddings and TTS still resolve every alias. Measured on a booted container: removing the bypass instead gives members an empty picker and HTTP 400 Model not found on chat, while leaving RAG and TTS unaffected, so the bypass protects chat and not the named risks. Guard: scripts/test_owui_model_picker_filter.py, with a --live mode that fails when the picker still lists the aliases.","tags":["open-webui","model-picker","access-control","inert-fix","issue-792","issue-776","docker-compose"]} ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary Fixes #947. `BYPASS_ADMIN_ACCESS_CONTROL` and `ENABLE_ADMIN_CHAT_ACCESS` both default to `true` upstream (`vendor/open-webui/backend/open_webui/config.py:2029`, `:2037`) and were never set in `deploy/docker/docker-compose.yml`. Because every sole tenant OWNER on this deployment is promoted to Open WebUI `admin` (56 live accounts hold that shape), any admin session could list every other tenant's Knowledge collections (`routers/knowledge.py:143`) and read any user's chats (`routers/chats.py:600`, `:1056`). The owner observed this live: a demo account's Knowledge collection visible from an unrelated personal account. This sets both to `"false"` on the `open-webui` service, next to the other hardcoded security defaults already there (`ENABLE_ADMIN_EXPORT`, `ENABLE_COMMUNITY_SHARING`). One service block covers every compose profile that starts Open WebUI (`local`, `enterprise`, `chat`). No `.env.example` entry: these are security defaults, not deployment-tunable knobs, matching the sibling flags in the same block. Both flags are plain `os.getenv(...)` reads at Python import time, not `PersistentConfig`, so unlike the RAG/audio/login-form keys `owui-patches/hive_rag_env_config.py` reconciles from the environment on every boot, these two have no database-persisted override to fight: the environment value is authoritative on the next container start. ## Explicitly out of scope (per brief) - Who holds the `admin` role (`tenant_role_from_db.py`, issue #748). Product decision, not this fix. - The Caddy `@adminMutation` matcher gap (issue #949). - The `/api/v1/configs/namespace/oauth` secret-exposure gap (issue #950). - Demoting or deleting any account. - An automated test suite. Owner's standing instruction: demo first, tests later. Follow-up regression-test issue: #953. ## Blast-radius check (what else this flag gates, and why the demo is unaffected) `BYPASS_ADMIN_ACCESS_CONTROL` is also read in `routers/models.py`, `files.py`, `prompts.py`, `tools.py`, `skills.py`, `notes.py` (the same admin-sees-everyone bypass, repeated). Two things matter for the demo: - **Knowledge** (`DEMO.md`: create own collection, upload own document, attach to own chat). Turning the bypass off only removes the branch that skips the `user_id`/group filter for admins; an admin's own collections stay visible (the filter becomes `user_id == own`, not deny-all). The demo only ever touches the signed-in account's own collection, so it is unaffected, and now correctly scoped besides. - **Model list** (chat picker, Workspace > Models). Gated by a *different* flag, `BYPASS_MODEL_ACCESS_CONTROL`, left untouched here. In the pinned v0.10.2 image that flag alone skips `get_filtered_models` for every role, independent of `BYPASS_ADMIN_ACCESS_CONTROL`, and the picker's own unconditional Hive filter (`owui-patches/hive_model_picker.py`) runs regardless of both flags. Model listing is identical before and after this change. `ENABLE_ADMIN_CHAT_ACCESS` only gates the admin-only `/list/user/{user_id}` endpoint (Users page's per-user chat listing) and the share-chat by-ID fallback. Neither is on the path a user takes to open their own chats. Files/prompts/tools/skills/notes admin-sees-everyone visibility also narrows to own+group under this same flag. Nothing in `DEMO.md` exercises an admin viewing another account's prompts, tools, skills or notes, so this is accepted collateral narrowing consistent with the fix's intent. ## Verification Manual, against an isolated local compose stack built from this branch (fresh Open WebUI volume, `local` + `chat` profiles). Two Open WebUI accounts created directly via the app's own internal APIs (no password set/read/rotated anywhere, neither the owner's account nor the shared demo account), both independently tenant-OWNER (so both admin), one owning a Knowledge collection the other has no relationship to. **Control, addressing review feedback that the original screenshots could not distinguish "the fix works" from "the test account was never admin":** same two accounts, same collection, captured twice against the same container/volume, flipping only the two flags between captures. Both accounts' `role=admin` confirmed via `GET /api/v1/auths/` at the moment of each capture. Proof posted as a release-asset comment (not `docs/proof/`, which 404s once this branch is deleted at merge): #960 (comment) - **Before** (flags forced back to upstream's default `true`): fixture B, an admin in a different tenant, sees fixture A's collection. - **After** (this branch's actual `docker-compose.yml`, both `false`): same fixture B sees "No knowledge found". ## Buglog entry For the follow-up buglog-only PR (per `.claude/rules/openwolf.md`, never appended directly on this branch): ```json {"error_message":"Open WebUI admin sessions could read every other tenant's Knowledge collections and any user's chats","root_cause":"BYPASS_ADMIN_ACCESS_CONTROL and ENABLE_ADMIN_CHAT_ACCESS default to true upstream and were never set in deploy/docker/docker-compose.yml, and every sole tenant OWNER on this deployment is promoted to Open WebUI admin","fix":"set both to \"false\" on the open-webui service in deploy/docker/docker-compose.yml, covering local/enterprise/chat profiles; no DB reconciliation needed since both are plain os.getenv reads at import time","tags":["security","open-webui","rbac","cross-tenant"]} ``` A second entry, for the branch contamination described at the bottom of this description: ```json {"error_message": "PR #960 carried five vendor/open-webui/src/lib/hive files and a hive.css hunk from the rejected #951 agent-surface design, under a PR titled for a tenant-isolation fix", "root_cause": "git checkout <ref> -- <path> stages what it checks out, so a later path-limited git commit -- docs/ committed the whole index including those staged vendor files rather than only the named paths", "fix": "rebuilt the branch from origin/main and checked out only the eight wanted paths instead of cherry-picking; verified the three source files byte-identical between merge base and main first, then confirmed with git status --short after the checkout and gh pr diff --name-only after the push", "tags": ["git", "process", "contamination", "open-webui"]} ``` ## Follow-up - #953: automated regression coverage for these two flags. ## Test plan - [x] Manual verification against a running stack, before/after control, both accounts' admin role confirmed via API (see Verification section) - [x] Confirmed via source read that the demo's Knowledge and model-list surfaces are unaffected - [x] Confirmed `/v1/rag/chat` enforces tenant scoping independently (Postgres RLS + explicit `tenant_id` filter in `apps/edge-api/internal/rag/repository.go`), not through the Open WebUI layer this PR changes; not a second door to the same data - [ ] CI (Go tests, web-console build). This PR touches only `deploy/docker/docker-compose.yml`, `deploy/docker/Caddyfile.owui`, `scripts/test_caddy_owui_blocklist.py` and `docs/`, no application code <!-- greptile_comment --> <details open><summary><h3>Greptile Summary</h3></summary> This PR closes Open WebUI’s administrator cross-tenant read paths by explicitly disabling the upstream admin access-control bypass and cross-user chat access defaults. - Applies both security defaults to the shared Open WebUI service used by the local, enterprise, and chat profiles. - Documents controlled before-and-after verification with two independently confirmed administrator accounts from different tenants. - Keeps image evidence in permanent release assets while committing the sanitized textual proof record. </details> <details open><summary><h3>Confidence Score: 5/5</h3></summary> The PR appears safe to merge and correctly narrows Open WebUI administrator reads to authorized user and group scope. The shared Compose service supplies both flags to every profile that starts Open WebUI, the pinned implementation parses the quoted false values correctly at startup, and the remaining router branches preserve access to owned or explicitly granted resources. </details> <details open><summary><h3>Important Files Changed</h3></summary> | Filename | Overview | |----------|----------| | deploy/docker/docker-compose.yml | Adds correctly parsed, environment-authoritative security defaults to the shared Open WebUI service, disabling administrator-wide content and chat access across all applicable profiles. | | docs/proof/owui-admin-bypass-fix-2026-08-17/README.md | Records a controlled before-and-after tenant-isolation demonstration while following the repository’s text-log, release-asset, and credential-handling requirements. | </details> <details><summary><h3>Flowchart</h3></summary> ```mermaid %%{init: {'theme': 'neutral'}}%% flowchart LR A[Open WebUI admin session] --> B{Access-control flags} B -->|Previous upstream defaults: true| C[Admin-wide Knowledge and chat reads] B -->|Compose defaults: false| D[User and group scoped access] D --> E[Own or explicitly granted resources] D -. blocks .-> F[Unrelated tenant resources] ``` </details> <sub>Reviews (1): Last reviewed commit: ["docs: commit the proof text log for hive..."](74811ed) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=54104603)</sub> <!-- /greptile_comment --> --- ## Rebase onto current main, 2026-08-22 Rebased onto `main` at `c30882491` and force pushed, so CI has now run against the current base rather than reporting a stale green. Branch protection on this repository has `strict=false`, so GitHub does not require an up-to-date branch and the previous CLEAN was measured against `1dc67ee69`, before the database moved off hosted Supabase and before `docker-compose.yml` was substantially rewritten around the `selfhost` profile and the layered override and enterprise files. No conflicts: the two lines land in the `open-webui` service's environment block, which none of that work touched. ### Still needed, verified rather than assumed - `main` sets neither variable: `git show origin/main:deploy/docker/docker-compose.yml | grep -E 'BYPASS_ADMIN|ENABLE_ADMIN_CHAT'` returns only the one pre-existing comment that mentions the default. So no later fix has superseded this. - Both are still plain `os.getenv` reads at import time in the vendored `config.py`, not `PersistentConfig`, so no reconciler entry is needed and a container recreate is sufficient. `BYPASS_ADMIN_ACCESS_CONTROL` still falls back to `ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS`, also defaulting true, which is why the line sets it directly. - Nothing about this depends on where Supabase runs. These flags gate Open WebUI's own authorization checks over its own tables, inside the pinned upstream backend image, and the migration changed neither. ### Nothing here can delete the data plane Recording this because compose is the one file where a rebase mistake is destructive. This branch adds two environment lines inside an existing service and changes no service list, no profile, no volume and no file layering. It does not touch `HIVE_COMPOSE_FLAGS`, does not remove `docker-compose.enterprise.yml` from the invocation, and does not alter the `selfhost` profile, so the `--remove-orphans` in the deploy recreate step has nothing new to orphan. ### One stale comment left in place, deliberately An unrelated comment further down the same service still reads that the per-model access control fix "could never fire" because `BYPASS_ADMIN_ACCESS_CONTROL` defaults true. After this change that premise no longer holds. It is left alone rather than rewritten here: it is the historical record of why #776 was abandoned, and rewriting history in a comment to match a later change is how the reasoning behind an abandoned approach gets lost. Worth a separate docs pass over that block. ### Visual proof The existing capture stands on its own terms and is not re-run. Its record is in `docs/proof/owui-admin-bypass-fix-2026-08-17/README.md`: two Open WebUI accounts, both confirmed `admin` via `GET /api/v1/auths/` at the moment of each capture rather than asserted in prose, one Knowledge collection, captured twice against the same container and volume with only these two flags flipped between captures, going from `bSeesFixtureACollection: true` to `false`. The reason it is not re-run is worth stating rather than leaving as an omission. That capture ran in an isolated local compose project against a fresh Open WebUI volume, using the container's own model layer and `create_token`; it never touched hosted Supabase, so the migration did not invalidate it, and the flags it exercises are read by the pinned upstream backend image which has not changed. Re-running it would produce the same two screenshots from the same inputs. If a fresh pair is wanted before merge regardless, say so and it can be re-captured. No credential appears in that capture: the session tokens were injected via `localStorage`, never a URL query string or fragment, so there was nothing to redact in either the text or the pixels. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Security** - Restricted administrators from accessing other tenants’ Knowledge collections and chats by default. - Preserved administrators’ access to content within their own tenant. - Changes take effect after the service restarts. - **Documentation** - Added verification records covering the updated cross-tenant access behavior, test setup, and scope limitations. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --- ## Branch rebuilt from `main`, 2026-08-23 Force pushed a clean rebuild. The previous head `b6c2850e5` also carried five files under `vendor/open-webui/src/lib/hive/` (`AgentTasks.svelte`, `ComposerSendButton.svelte`, `ComposerShell.svelte`, `agentTasks.ts`, `agentTasks.test.ts`) plus a `hive.css` hunk that existed only to style them. Those belong to the separate agent-surface change in #951, whose design was rejected, and none of them exists on `main`. Merging as-is would have shipped a rejected design under a PR titled for a tenant-isolation fix. How they got there, since the mechanism is not obvious: `git checkout <ref> -- <path>` stages what it checks out, and a later path-limited `git commit -- docs/` still commits the whole index rather than only the named paths. The staged vendor files rode along silently. The lesson is to run `git status --short` after any path-limited checkout, not before. The rebuild branched from `origin/main` and checked out only the eight wanted paths, rather than cherry-picking, which would have dragged the same contamination forward. `deploy/docker/Caddyfile.owui`, `deploy/docker/docker-compose.yml` and `scripts/test_caddy_owui_blocklist.py` were first verified byte-identical between the branch's merge base and current `origin/main`, so taking them from the old branch head reproduces exactly the intended edit and reverts no later work on those files. The diff is now eight files, 326 insertions and 1 deletion, with zero `vendor/open-webui` paths. `python3 scripts/test_caddy_owui_blocklist.py` reports 43 blocked, 40 open. Nothing load-bearing was lost. The tenant-isolation fix is the two compose flags plus the Caddy block, and none of the five removed files is referenced by either. The spec's section 8 records that `/v1/agent/*` is untouched, so the agent plumbing and the security fix were never coupled. ### The proof predates the strip and still holds The before-and-after capture was taken against the pre-strip branch and is deliberately not re-run. Everything it exercises survived the strip unchanged: the two compose flags, the Caddy block and both fixture tenants are all still present and byte-identical. The strip removed only frontend Svelte and TypeScript files that no part of the captured path references. The capture therefore still describes the behaviour this PR ships. One detail from that capture is worth stating plainly, because it is the most valuable line in the whole proof. The first attempt at the after arm ran before the container was healthy and answered 401 to everything. That would have read as a clean pass on the strength of nobody being signed in: no collection visible, because no session. The script now aborts unless both roles verify as `admin` through `GET /api/v1/auths/` before either arm is captured, which is exactly what lets the recorded run distinguish "the fix works" from "the test was never authenticated".
…1606) ## Defect `hive-free` and `hive-free-tools` shipped `visibility='public'`, in the default policy group, so any tenant could select and invoke them from the chat model picker. Two reasons that is wrong for a demo, and wrong generally: 1. `hive-free` load-balances across four Hive-owned free provider keys and is the current source of sustained rate limiting. CI's Live integration job answers "429 hive-free is temporarily rate limited" today, and issue #1566 records the pool's Gemini member capped at 20 requests/day, 435 failures in 48 hours. A customer or the owner picking that alias gets a 429. 2. `hive-free-tools` is served anonymously by a third party we hold no account with, disclosed in its own alias summary: no data processing agreement, no deletion path. That should not be one click away in the picker. ## The fix, and why this mechanism `visibility='restricted'` on both aliases via a new migration (`supabase/migrations/20260831_01_restrict_free_pool_aliases_visibility.sql`). `catalog.AliasVisibleToTenant` (`apps/control-plane/internal/catalog/visibility.go`) fails an alias closed for every tenant with no explicit `tenant_model_visibility(visible=true)` row, and it is the same predicate the catalog listing (the picker) and `routing.Service.SelectRoute` (actual invocation, every inference surface) both resolve through. The picker cannot disagree with what the API accepts. This is deliberately **not** a picker-side id blocklist (`HIVE_PICKER_HIDDEN_MODEL_IDS` or similar). A hardcoded id list lives in a vendored handler, splices in at build time, and goes silently inert the moment the compose bypass flag or the pinned image shifts — that is exactly how issue #776 died, and the owner has ruled against this shape of band-aid. The visibility rail is schema-level and both surfaces read it, so there is no second place for the two answers to drift apart. `model_policy_group_members` is untouched: both aliases stay in the `default` and `closed` policy groups. That table gates which aliases an API key's pricing tier can see at all; visibility gates which tenant can see or invoke a given alias. They are independent axes. ## No grant rows in the migration, on purpose The migration only flips the `visibility` column. It inserts zero `tenant_model_visibility` rows: a schema migration runs identically against every deployment (CI throwaway, the demo box, a future enterprise install), and a specific tenant's UUID is per-deployment operational data, not something a portable migration should encode. ## Requirement 4: internal consumers, checked CI lanes and the daily free-pool lanes consume the free pool deliberately (see `20260824_02_free_pool_router.sql` Part C), so I checked every automated caller that invokes through a tenant-scoped path (an API key is tenant-scoped as of D-030, so `routing.Service.SelectRoute` runs the entitlement check on every one of these). **Fixed in this PR, verified working:** - CI's `live-integration` job (`.github/workflows/ci.yml`) seeds a fresh throwaway tenant on every run via `scripts/ci-seed-api-key.sh`, and its default `HIVE_TEST_MODEL`/`HIVE_TOOLS_MODEL` is `hive-free`. That script now grants its own tenant `visible=true` on both `hive-free` and `hive-free-tools`, in the same place that mints the tenant, plus an assertion that the grant landed. Verified locally end to end against a real throwaway Postgres: `apply-migrations.sh` applied the restriction, then `ci-seed-api-key.sh` ran and its new assertion (`the tenant is granted both restricted free-pool aliases`, expects count 2) passed. See the live proof transcript below for the same grant proven over HTTP. **NOT fixed in this PR, will break, needs one operator action:** - `deploy-demo-box.yml`'s `sdk-replay` job (`HIVE_API_KEY: ${{ secrets.HIVE_API_KEY }}`, `HIVE_TEST_MODEL: hive-free`, `HIVE_TOOLS_MODEL: hive-free`), `scripts/post-deploy-verify.py` (daily lane, `HIVE_VERIFY_MODEL` defaults to `hive-free`, a real billed completion against a real persistent verification tenant), and `scripts/verify-control-plane.py` (hardcoded `"model": "hive-free"`) all authenticate against the **real, persistent demo box**, not an ephemeral CI tenant. I did not grant that tenant, and I am not guessing its exact UUID into a schema migration — that is exactly the kind of per-deployment operational data the "no grant rows in the migration" reasoning above argues against baking into portable schema. **Required follow-up, before or immediately after this PR's next deploy:** grant the demo box's verification tenant (default slug `hive-demo` per `scripts/verify-control-plane.py`'s `HIVE_VERIFY_TENANT_SLUG` default and `scripts/seed-demo-owner.py`'s `HIVE_DEMO_TENANT_SLUG` default — confirm the actual tenant behind `HIVE_VERIFY_EMAIL`/`HIVE_API_KEY` before running this) `visible=true` on both aliases, either through the existing admin endpoint (`PUT /internal/catalog/visibility/{tenantID}/{aliasID}`, body `{"visible":true}`, `X-Internal-Token: $CONTROL_PLANE_INTERNAL_TOKEN`) or with this idempotent statement against the box's database: ```sql INSERT INTO public.tenant_model_visibility (tenant_id, alias_id, visible) SELECT t.id, a.alias_id, true FROM public.tenants t CROSS JOIN (VALUES ('hive-free'), ('hive-free-tools')) AS a(alias_id) WHERE t.slug = 'hive-demo' ON CONFLICT (tenant_id, alias_id) DO UPDATE SET visible = true, updated_at = now(); ``` Until that runs, `sdk-replay`, the daily `post-deploy-verify.py` lane, and `verify-control-plane.py` will all start failing with `routing: model not entitled for tenant: alias hive-free` on the next deploy/cron. ## Requirement 5: what an existing tenant experiences An ordinary tenant that has already used `hive-free` or `hive-free-tools` (via the chat picker or a direct API call) will, from the next request after this deploys: - No longer see either alias in the model picker (`GET /v1/models` / the catalog snapshot both stop listing them, proven below). - Get `HTTP 403` with `routing: model not entitled for tenant: alias hive-free` (or `hive-free-tools`) on any direct API call naming either alias, instead of a completion. - See no change to any other alias: `model_policy_group_members` is untouched and every other alias's visibility is untouched. On the demo box specifically, Open WebUI's own picker reflects this automatically on the next control-plane boot: `ReconcileOWUISync` runs at control-plane startup (`apps/control-plane/cmd/server/main.go`) and locks any alias whose visibility changed behind an OWUI access-control group, the same mechanism issue #772 added for migration-seeded rows. No manual OWUI-side step is needed; the operator action above is only for the tenant-level grant that keeps the automated verification checks passing. ## Test Extended the existing restricted-visibility integration coverage (`apps/control-plane/internal/catalog/catalog_integration_test.go`, `TestTenantVisibilityIntegration`) rather than duplicating it: added `TestFreePoolAliasesAreRestrictedFromTenants`, which names the two real aliases directly and checks both the listing path (`ListModelsForTenant`) and the exact invocation-gate call `routing.Service.SelectRoute` makes (`IsAliasVisibleToTenant`). Proven RED for the right reason, then GREEN, against a real throwaway Postgres (not asserted, actually run): ``` === RUN TestFreePoolAliasesAreRestrictedFromTenants catalog_integration_test.go:285: hive-free: visibility = "public", want "restricted" (20260831_01_restrict_free_pool_aliases_visibility.sql not applied?) catalog_integration_test.go:293: hive-free: IsAliasVisibleToTenant = true for a tenant with no visibility grant, want false ... catalog_integration_test.go:302: hive-free must be absent from the picker listing for a tenant with no grant --- FAIL: TestFreePoolAliasesAreRestrictedFromTenants (0.17s) ``` then, after applying the new migration: ``` === RUN TestFreePoolAliasesAreRestrictedFromTenants catalog_integration_test.go:308: TestFreePoolAliasesAreRestrictedFromTenants: hive-free and hive-free-tools both locked for tenant=c0000000-0000-0000-0000-000000000001 with no grant --- PASS: TestFreePoolAliasesAreRestrictedFromTenants (0.13s) ``` `scripts/apply-migrations.sh --check`: `baseline file OK: applied=61 pending=63 migrations=124`. Full chain applied cleanly against a scratch `pgvector/pgvector:pg17` Postgres (`123` then `+1` migrations), re-running the new migration directly reports `UPDATE 0` (idempotent no-op). `go test ./apps/control-plane/... -count=1 -short` and `go test ./apps/edge-api/... -count=1 -short`: all green, no regressions. ## Visual proof This PR touches zero UI/frontend code (one SQL migration, one CI shell script, one Go test file). Open WebUI on this deployment is SSO-only by design (`ENABLE_LOGIN_FORM` and `ENABLE_SIGNUP` are both hardcoded `"false"` in `deploy/docker/docker-compose.yml`'s `open-webui` service; every account is provisioned through the Hive OIDC provider). Producing a literal browser screenshot of the picker means standing up the full self-hosted Supabase/GoTrue/PostgREST/Caddy OIDC chain, the same topology the demo box runs, not something a local verification pass should improvise. Instead, `docs/proof/restrict-free-aliases-visibility/` carries a live HTTP transcript against a real running control-plane (built from this branch) talking to a scratch Postgres with the full real migration chain applied, reproducible with `run-proof.sh`. Same shape as this repo's own precedent for the original tenant-entitlement feature, `docs/proof/tenant-model-entitlement/` (also transcript-only, no image, for the same class of change). It hits `/internal/catalog/snapshot/tenant/{tenantID}`, the literal tenant-scoped catalog snapshot `GET /v1/models` (what OWUI's own model dropdown calls) resolves through, and shows a real 10-model list for an ordinary tenant against a real 12-model list for a granted tenant, the difference being exactly `hive-free` and `hive-free-tools`. Full transcript: `docs/proof/restrict-free-aliases-visibility/live-transcript.md`. ## Buglog entry ```json {"date":"2026-08-31","error_message":"hive-free and hive-free-tools were visibility='public', so any tenant could select and invoke the rate-limited free pool and the no-DPA anonymous-upstream alias from the chat picker","root_cause":"both aliases were seeded public in their own migrations (20260824_02, 20260830_04) with no consideration of tenant-level entitlement, and no downstream check ever restricted them","fix":"set visibility='restricted' on both aliases via a new migration; catalog.AliasVisibleToTenant then fails them closed for every tenant with no explicit grant, on both the picker listing and routing.Service.SelectRoute invocation path; CI's own ephemeral tenant seeding (scripts/ci-seed-api-key.sh) now grants itself explicit visibility so the live-integration lane stays green","tags":["catalog","visibility","free-pool","rate-limiting","demo-readiness"]} ``` ## Test plan - [x] `scripts/apply-migrations.sh --check` - [x] Full migration chain applied against scratch Postgres, new migration idempotency proven (`UPDATE 0` on re-run) - [x] `TestFreePoolAliasesAreRestrictedFromTenants` proven RED pre-migration, GREEN post-migration - [x] `scripts/ci-seed-api-key.sh` re-run against post-migration DB, new grant assertion passes - [x] `go test ./apps/control-plane/...` and `go test ./apps/edge-api/...` (short) both green - [x] Live transcript proof against a real running control-plane - [ ] Operator: grant the demo box's verification tenant visibility on both aliases (see Requirement 4 above) before/at next deploy
…e kill switch operable (issue #1718) Three findings from the review pass on the merged head. The first is a real defect that would have shipped this feature merged and inert. GET /v1/tools was unreachable. The handler's own doc comment calls the route deliberately unauthenticated, and the chat shim reads it with no Authorization header on purpose, but authSelectorMiddleware sends everything under /v1/ with no bearer to the JWT handler, which answers 401 before any mux entry runs. On any deployment with Supabase JWT auth wired, which includes the demo box, the shim's read would have returned 401 on every turn, advertised nothing, and prefer_legacy would have put every turn back on the legacy path. That is a merged feature that never runs, the exact shape of issue #776. The selector now exempts that one path and that one method, spelled through a shared webToolsListPath constant so the exemption and the registration cannot drift apart. Both call routes spend credits and keep their authentication, and so does a non-GET to the list path. Two tests pin both directions against the real authSelectorMiddleware construction: the list is reachable with no credential, and web_search, web_fetch, a POST and a DELETE to the list path, a trailing slash, a prefix neighbour and /v1/models all still reach the JWT path instead of the mux. Removing the exemption turns the first one red. The deploy workflow forced OWUI_WEB_TOOLS_ENABLED to true. The reasoning that justifies forcing OWUI_DEFAULT_FUNCTION_CALLING does not carry over: the box's .env holds a stale legacy value for function calling, which is why the shell environment has to win there, whereas compose already defaults the web tools to true and the forced value would instead override the one setting an operator would reach for to turn the feature off during an incident. A switch the deployment cannot honour is worse than no switch. The self-check assertion that pinned the override now pins its absence, and pins the compose default that replaces it. Last, patch() said it applies three edits where it applies four, which the module docstring and the Dockerfile marker count both already state correctly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WbVmp2Uh7FCgnqKB2TuBb5
Summary
Fixes #772. Open WebUI's chat model dropdown listed
hive-embedding-default,hive-sttandhive-ttsas selectable chat models. Picking one produced a broken conversation, since none of them serve chat completions.Two fixes were considered and rejected per the issue's analysis:
/v1/modelsin edge-api would violate the OpenAI contract (packages/openai-contract/matrix/support-matrix.json:350marks thissupported_now, and upstream OpenAI lists embedding/audio ids too). Direct API clients must keep seeing everything.restrictedintenant_model_visibilitywould also block invocation (AliasVisibleToTenantdeliberately couples hidden to not-invocable), breaking the shim account's own embedding/STT/TTS calls and taking out RAG and voice.The fix
apps/control-plane/internal/catalog/http.go—syncOWUInow locks any alias whosecapability_badgesintersect{embeddings, stt, tts, voice}into the existinghive-restricted-placeholderOWUI group, independent of the alias'sVisibilityclass or anytenant_model_visibilitygrant. The placeholder-assignment logic (previously inline in two branches) is factored intolockOWUIModel, shared by the non-chat-modality lock and the pre-existing restricted-with-no-grants lock.The predicate (
isNonChatModality) is exclusion-based (does any badge match a known non-chat tag), not inclusion-based on a"chat"badge:hive-auto's real seed row is["auto","fallback","preview"]with no explicit"chat"badge, so a chat-badge whitelist would have wrongly hidden it from the picker. Verified against the actual migration-seeded badges:hive-embedding-default:["stable","embeddings"]hive-stt:["voice","stt"]hive-tts:["voice","tts"]hive-auto:["auto","fallback","preview"](must stay chat-selectable)apps/control-plane/internal/catalog/reconcile.go(new) —VisibilityHandler.ReconcileOWUISyncwalks every alias via a newRepository.ListAllAliasesand re-runssyncOWUIfor each.syncOWUIpreviously only ran from the admin PUT/DELETE visibility-mutation path, which the three migration-seeded aliases never went through — this is why the dropdown has shown them since the day they were added. Called once at boot fromapps/control-plane/cmd/server/main.go, right after the OWUI client andcatalogVisibilityHandlerare wired. Best-effort (logs a warning, does not block startup), and safe to call repeatedly — it's also self-healing across an Open WebUI image bump that resetsaccess_controlon its own model rows.model_aliases.capability_badgesis freeform jsonb with no CHECK constraint and no dedicated modality column, so this is a bounded shortcut — see theponytail:comment onnonChatModalityBadgesinhttp.go. The real fix is a modality oris_chat_modelcolumn onmodel_aliases.TDD
Tests written first; RED was a genuine compile failure (
isNonChatModality/ReconcileOWUISyncundefined) against the pre-fix code, confirmed by temporarily reverting the implementation and re-running the suite:GREEN after restoring the implementation, full
catalogpackage (36 test functions incl. subtests):TestSyncOWUI_PublicAlias_SkipsSync(pre-existing, T5) was narrowed to a genuinely chat-shaped alias (real chat badges["stable","chat","responses"]instead of an emptyCapabilityBadgesslice) — it previously passed even with no badges at all, so it was not actually distinguishing "public chat alias, skip sync" from "public alias with no badges, skip sync";TestSyncOWUI_PublicNonChatAlias_UsesPlaceholderis the case that must NOT skip sync despite also beingVisibility=="public".Also ran, all green: full
go build ./apps/control-plane/...,go vet ./apps/control-plane/...,gofmt -l(no output), and the entirego test ./apps/control-plane/...suite (every packageok).No visual proof possible without a deploy
This change only takes effect on
control-planerestart (the boot reconcile) or on the next admin visibility PUT/DELETE for these aliases; no live box is being touched by this PR (per the standing directive, another agent owns merge/deploy). What a reviewer should see after acontrol-planerestart on the box:hive-embedding-default,hive-stt, orhive-tts.GET /v1/models(and/api/v1/catalog/models) still return all six aliases including those three — the OpenAI-contract listing is untouched, only the OWUI-sideaccess_controlchanged./v1/rag/chat, which useshive-embedding-default) still works./v1/audio/transcriptions) and text-to-speech (/v1/audio/speech) still work.owui non-chat-modality reconcile complete (issue #772)(or aWARNING:line if OWUI was unreachable — non-fatal, does not block boot).Test plan
control-planerestarted on the demo/staging box withOWUI_BASE_URL/OWUI_ADMIN_TOKENsetowui non-chat-modality reconcile complete (issue #772)hive-embedding-default/hive-stt/hive-ttscurlGET /v1/modelsstill returns all six aliases/v1/audio/transcriptionsand/v1/audio/speechstill succeed🤖 Generated with Claude Code