Skip to content

fix: hide embedding/stt/tts aliases from the OWUI chat picker (#772) - #776

Merged
sakibsadmanshajib merged 1 commit into
mainfrom
fix/owui-hide-non-chat-modality-772
Aug 8, 2026
Merged

sakibsadmanshajib merged 1 commit into
mainfrom
fix/owui-hide-non-chat-modality-772

Conversation

@sakibsadmanshajib

Copy link
Copy Markdown
Owner

Summary

Fixes #772. 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, since none of them serve chat completions.

Two fixes were considered and rejected per the issue's analysis:

  • Filtering these out of /v1/models in edge-api would violate the OpenAI contract (packages/openai-contract/matrix/support-matrix.json:350 marks this supported_now, and upstream OpenAI lists embedding/audio ids too). Direct API clients must keep seeing everything.
  • Marking them restricted in tenant_model_visibility would also block invocation (AliasVisibleToTenant deliberately couples hidden to not-invocable), breaking the shim account's own embedding/STT/TTS calls and taking out RAG and voice.

The fix

  1. apps/control-plane/internal/catalog/http.go — syncOWUI now locks any alias whose capability_badges intersect {embeddings, stt, tts, voice} into the existing hive-restricted-placeholder OWUI group, independent of the alias's Visibility class or any tenant_model_visibility grant. The placeholder-assignment logic (previously inline in two branches) is factored into lockOWUIModel, 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)
  2. apps/control-plane/internal/catalog/reconcile.go (new) — VisibilityHandler.ReconcileOWUISync walks every alias via a new Repository.ListAllAliases and re-runs syncOWUI for each. syncOWUI previously 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 from apps/control-plane/cmd/server/main.go, right after the OWUI client and catalogVisibilityHandler are 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 resets access_control on its own model rows.

model_aliases.capability_badges is freeform jsonb with no CHECK constraint and 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 on model_aliases.

TDD

Tests written first; RED was a genuine compile failure (isNonChatModality / ReconcileOWUISync undefined) against the pre-fix code, confirmed by temporarily reverting the implementation and re-running the suite:

apps/control-plane/internal/catalog/http_test.go:388:14: undefined: isNonChatModality
apps/control-plane/internal/catalog/reconcile_test.go:54:15: vh.ReconcileOWUISync undefined (type *VisibilityHandler has no field or method ReconcileOWUISync)
FAIL	github.com/sakibsadmanshajib/hive/apps/control-plane/internal/catalog [build failed]

GREEN after restoring the implementation, full catalog package (36 test functions incl. subtests):

--- PASS: TestSyncOWUI_PublicNonChatAlias_UsesPlaceholder (0.00s)
--- PASS: TestIsNonChatModality (0.00s)
    --- PASS: TestIsNonChatModality/hive-auto_has_no_chat_badge_but_is_chat-selectable (0.00s)
    --- PASS: TestIsNonChatModality/hive-embedding-default_badges (0.00s)
    --- PASS: TestIsNonChatModality/hive-stt_badges (0.00s)
    --- PASS: TestIsNonChatModality/hive-tts_badges (0.00s)
--- PASS: TestReconcileOWUISync_MigrationSeededNonChatAlias_GetsPlaceholder (0.00s)
--- PASS: TestReconcileOWUISync_NilOWUI_NoOp (0.00s)
--- PASS: TestReconcileOWUISync_RepositoryError_ReturnsError (0.00s)
--- PASS: TestSyncOWUI_PublicAlias_SkipsSync (0.00s)
...
ok  	github.com/sakibsadmanshajib/hive/apps/control-plane/internal/catalog	0.030s

TestSyncOWUI_PublicAlias_SkipsSync (pre-existing, T5) was narrowed to a genuinely chat-shaped alias (real chat badges ["stable","chat","responses"] instead of an empty CapabilityBadges slice) — 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_UsesPlaceholder is the case that must NOT skip sync despite also being Visibility=="public".

Also ran, all green: full go build ./apps/control-plane/..., go vet ./apps/control-plane/..., gofmt -l (no output), and the entire go test ./apps/control-plane/... suite (every package ok).

No visual proof possible without a deploy

This change only takes effect on control-plane restart (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 a control-plane restart on the box:

  • Open WebUI's model/chat dropdown no longer lists hive-embedding-default, hive-stt, or hive-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-side access_control changed.
  • RAG (/v1/rag/chat, which uses hive-embedding-default) still works.
  • Speech-to-text (/v1/audio/transcriptions) and text-to-speech (/v1/audio/speech) still work.
  • Startup logs show owui non-chat-modality reconcile complete (issue #772) (or a WARNING: line if OWUI was unreachable — non-fatal, does not block boot).

Test plan

  • control-plane restarted on the demo/staging box with OWUI_BASE_URL/OWUI_ADMIN_TOKEN set
  • Confirm startup log line owui non-chat-modality reconcile complete (issue #772)
  • Open WebUI chat dropdown no longer shows hive-embedding-default / hive-stt / hive-tts
  • curl GET /v1/models still returns all six aliases
  • RAG query still succeeds
  • /v1/audio/transcriptions and /v1/audio/speech still succeed

🤖 Generated with Claude Code

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>
@cursor

cursor Bot commented Aug 6, 2026

Copy link
Copy Markdown

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.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sakibsadmanshajib, you've reached your PR review limit, so we couldn't start this review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b95a677c-9a07-435f-b0de-705333007378

📥 Commits

Reviewing files that changed from the base of the PR and between eb9f335 and bf3fd8f.

📒 Files selected for processing (7)
  • apps/control-plane/cmd/server/main.go
  • apps/control-plane/internal/catalog/http.go
  • apps/control-plane/internal/catalog/http_test.go
  • apps/control-plane/internal/catalog/reconcile.go
  • apps/control-plane/internal/catalog/reconcile_test.go
  • apps/control-plane/internal/catalog/repository.go
  • apps/control-plane/internal/catalog/service_test.go

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sakibsadmanshajib sakibsadmanshajib added good first issue Good for newcomers and removed good first issue Good for newcomers labels Aug 8, 2026
@sakibsadmanshajib
sakibsadmanshajib merged commit a078f09 into main Aug 8, 2026
31 checks passed
@sakibsadmanshajib
sakibsadmanshajib deleted the fix/owui-hide-non-chat-modality-772 branch August 8, 2026 16:54
sakibsadmanshajib added a commit that referenced this pull request Aug 17, 2026
…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 before, all six
aliases](https://raw.githubusercontent.com/sakibsadmanshajib/hive/fix/model-picker-access-control-792/docs/proof/issue-792/01-before-picker-owner.png)

Picker after, same persona, same harness, patched image. Three chat
aliases:

![picker after, three chat
aliases](https://raw.githubusercontent.com/sakibsadmanshajib/hive/fix/model-picker-access-control-792/docs/proof/issue-792/02-after-picker-owner.png)

The gateway's own list in that same session, post-fix, still carrying
all six:

![gateway list still returns
six](https://raw.githubusercontent.com/sakibsadmanshajib/hive/fix/model-picker-access-control-792/docs/proof/issue-792/04-after-gateway-list-unfiltered.png)

And `/api/models`, the listing the picker reads, post-fix:

![api models listing after the
fix](https://raw.githubusercontent.com/sakibsadmanshajib/hive/fix/model-picker-access-control-792/docs/proof/issue-792/03-after-picker-listing-endpoint.png)

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:

![picker built from main, six
aliases](https://raw.githubusercontent.com/sakibsadmanshajib/hive/fix/model-picker-access-control-792/docs/proof/pr-814-rebase-verification-2026-08-17/picker-main.png)

Built from this branch — three chat aliases:

![picker built from this branch, three
aliases](https://raw.githubusercontent.com/sakibsadmanshajib/hive/fix/model-picker-access-control-792/docs/proof/pr-814-rebase-verification-2026-08-17/picker-branch.png)

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)
sakibsadmanshajib added a commit that referenced this pull request Aug 23, 2026
## 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".
sakibsadmanshajib added a commit that referenced this pull request Aug 31, 2026
…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
sakibsadmanshajib added a commit that referenced this pull request Sep 2, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Open WebUI chat surface exposes infra model aliases and duplicate/unscoped product surfaces (models, playground, automations, calendar, notes)

1 participant