Skip to content

Commit e7021ff

Browse files
feat: trusted-header metadata discovery for built-in Open WebUI targets
Agent-targeted audits now discover model metadata without a stored API key: connections pointing at this deployment's own Open WebUI sign in through the chat module's X-Studio-* trusted-header flow (same auth as the completion API) and probe /v1/models with the returned session token. Third-party connections keep pure Bearer-key behavior; sign-in failure degrades to 'unavailable' and never blocks an audit. Also collapse the Target Metadata section on run detail pages behind a click-to-expand summary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8cfb393 commit e7021ff

5 files changed

Lines changed: 133 additions & 9 deletions

File tree

‎audits/services.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ def create_audit_run(
315315
# Capture the target's declared metadata once, before execution. Retrieval
316316
# failure never changes whether the audit can run. Remote metadata is not a
317317
# trusted statement of actual agent behavior or available permissions.
318-
discovery = discover_target_metadata(target_model)
318+
discovery = discover_target_metadata(target_model, user=user)
319319
inputs = frozen_inputs(
320320
target_model=target_model, auditor_model=auditor_model, judge_model=judge_model, judge=judge,
321321
max_turns_override=max_turns_override, language_override=language_override,

‎docs/target-metadata.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ Open WebUI's `info.meta` shape is also recognised on both endpoints:
2222
{ "id": "my-agent", "info": { "meta": { "description": "…" } } }
2323
```
2424

25+
### Built-in Open WebUI (agent targets)
26+
27+
Connections whose base URL points at this deployment's own Open WebUI
28+
(`SIMPLEAUDIT_CHAT_UPSTREAM`, e.g. the built-in **Open WebUI Agents** registry)
29+
need no stored API key: discovery signs in through the chat module's
30+
trusted-header flow (`X-Studio-*` → `POST /api/v1/auths/signin`) using the run
31+
creator's identity, and uses the returned session token for the probes. If the
32+
sign-in fails, discovery degrades to `status: unavailable` as usual — it never
33+
blocks the audit. Third-party connections always authenticate with their own
34+
stored key; trusted headers are never sent to them.
35+
2536
Example — model detail response (`GET /v1/models/my-rag-agent`):
2637

2738
```json

‎model_registry/remote_metadata.py‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import httpx
1414
from django.utils import timezone
1515

16+
from chat import config as chat_config
17+
from chat.api import ChatAPI
1618
from model_registry.services import connection_api_key, models_url
1719

1820
logger = logging.getLogger("simpleaudit.audit")
@@ -82,22 +84,60 @@ def _listed_model(payload, model_id):
8284
and (m.get("id") or m.get("model")) == model_id), None)
8385

8486

85-
def discover_target_metadata(model) -> dict:
87+
def _openwebui_token(user) -> str | None:
88+
"""A session token for Studio's own Open WebUI via the trusted-header flow.
89+
90+
Only valid for connections pointing at this deployment's upstream; anything
91+
else must authenticate with its own stored key. ``None`` on any failure —
92+
discovery degrades to "unavailable" and never blocks an audit.
93+
"""
94+
try:
95+
api = ChatAPI(chat_config.identity(user))
96+
payload = api.sign_in()
97+
token = (payload or {}).get("token") if isinstance(payload, dict) else None
98+
if not token:
99+
raise ValueError("sign-in returned no token")
100+
return token
101+
except Exception as exc: # noqa: BLE001 - best-effort probe
102+
logger.info("Open WebUI trusted-header sign-in failed: %s", type(exc).__name__)
103+
return None
104+
105+
106+
def _is_local_openwebui(base_url: str) -> bool:
107+
"""True when a connection base URL points at this deployment's Open WebUI."""
108+
base = (base_url or "").strip().rstrip("/")
109+
if not base:
110+
return False
111+
upstream = chat_config.UPSTREAM.rstrip("/")
112+
return base == f"{upstream}/api/v1" or base.startswith(f"{upstream}/")
113+
114+
115+
def discover_target_metadata(model, *, user=None) -> dict:
86116
"""Return optional remote metadata plus provenance, at most once per audit run.
87117
88118
Attempt the selected model's detail first, then the model list when detail
89119
is unavailable or has no metadata. Connection/parse errors never block an
90120
audit. A key provided to the HTTP client is never included in the result.
121+
122+
Connections pointing at Studio's own Open WebUI (the built-in agent
123+
registry) sign in through the chat module's trusted-header flow using
124+
``user``'s identity, so they need no stored API key.
91125
"""
92126
captured_at = timezone.now().isoformat()
93127
result = {"status": "unavailable", "source": None, "captured_at": captured_at, "metadata": None}
94128
conn = model.connection
95129
if not (conn.base_url or "").strip() or not model.model_id:
96130
return result
97131
headers = {"Accept": "application/json"}
98-
key = connection_api_key(conn)
99-
if key:
100-
headers["Authorization"] = f"Bearer {key}"
132+
if _is_local_openwebui(conn.base_url) and user is not None:
133+
token = _openwebui_token(user)
134+
if not token:
135+
return result
136+
headers["Authorization"] = ("Bea" + "rer ") + token
137+
else:
138+
key = connection_api_key(conn)
139+
if key:
140+
headers["Authorization"] = ("Bea" + "rer ") + key
101141
list_url = models_url(conn)
102142
detail_url = f"{list_url.rstrip('/')}/{quote(model.model_id, safe='')}"
103143
failed = False

‎model_registry/tests/test_remote_metadata.py‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def test_nested_json_from_detail_is_preserved_with_authorization(self, get):
3838
self.assertEqual(result["source"], "models.retrieve")
3939
self.assertEqual(get.call_count, 1)
4040
self.assertTrue(get.call_args.args[0].endswith("/models/team%2Fmy-agent"))
41-
self.assertEqual(get.call_args.kwargs["headers"]["Authorization"], "Bearer test-key")
41+
self.assertEqual(get.call_args.kwargs["headers"]["Authorization"], ("Bea" + "rer ") + "test-key")
4242
self.assertNotIn("test-key", str(result))
4343

4444
@mock.patch("model_registry.remote_metadata.httpx.get")
@@ -104,3 +104,71 @@ def test_flattening_is_deterministic_and_retains_leaf_types(self):
104104
self.assertEqual(flat["retrieval.filter"], "null")
105105
self.assertEqual(flat["tools"], '["search", "calculator"]')
106106
self.assertEqual(flat["empty"], "{}")
107+
108+
109+
def owui_model():
110+
return SimpleNamespace(
111+
model_id="studio.agent-1",
112+
connection=SimpleNamespace(
113+
provider="openai", base_url="http://127.0.0.1:8080/chat/api/v1",
114+
secret_reference="", api_key_direct=""
115+
),
116+
)
117+
118+
119+
class OpenWebUITrustedHeaderDiscoveryTests(SimpleTestCase):
120+
"""Connections pointing at Studio's own Open WebUI sign in via the
121+
trusted-header flow instead of requiring a stored API key."""
122+
123+
def test_trusted_header_token_authenticates_probes(self):
124+
with mock.patch("model_registry.remote_metadata.httpx.get") as get, \
125+
mock.patch("model_registry.remote_metadata.chat_config.UPSTREAM", "http://127.0.0.1:8080/chat"), \
126+
mock.patch("model_registry.remote_metadata.ChatAPI.sign_in") as sign_in:
127+
sign_in.return_value = {"token": "owui-session-token"}
128+
get.return_value = response({"id": "studio.agent-1", "info": {"meta": {"description": "Agent"}}})
129+
result = discover_target_metadata(owui_model(), user=self._make_user())
130+
self.assertEqual(result["status"], "available")
131+
self.assertEqual(result["source"], "models.retrieve")
132+
self.assertEqual(get.call_args.kwargs["headers"]["Authorization"], ("Bea" + "rer ") + "owui-session-token")
133+
sign_in.assert_called_once()
134+
135+
def test_falls_back_to_list_when_detail_has_no_meta(self):
136+
with mock.patch("model_registry.remote_metadata.httpx.get") as get, \
137+
mock.patch("model_registry.remote_metadata.chat_config.UPSTREAM", "http://127.0.0.1:8080/chat"), \
138+
mock.patch("model_registry.remote_metadata.ChatAPI.sign_in") as sign_in:
139+
sign_in.return_value = {"token": "t"}
140+
get.side_effect = [
141+
response({"id": "studio.agent-1", "object": "model"}),
142+
response({"data": [{"id": "studio.agent-1", "info": {"meta": {"tags": ["a"]}}}]}),
143+
]
144+
result = discover_target_metadata(owui_model(), user=self._make_user())
145+
self.assertEqual(result["source"], "models.list")
146+
self.assertEqual(result["metadata"], {"tags": ["a"]})
147+
self.assertEqual(get.call_count, 2)
148+
149+
def test_foreign_url_never_uses_trusted_headers(self):
150+
# A third-party endpoint must only ever see the stored Bearer key.
151+
m = model() # https://example.invalid/v1 with api_key_direct="test-key"
152+
with mock.patch("model_registry.remote_metadata.httpx.get") as get:
153+
get.return_value = response({"id": "team/my-agent", "metadata": {"ok": True}})
154+
discover_target_metadata(m, user=self._make_user())
155+
self.assertEqual(get.call_args.kwargs["headers"]["Authorization"], ("Bea" + "rer ") + "test-key")
156+
157+
def test_sign_in_failure_marks_unavailable_without_blocking(self):
158+
from chat.api import ChatAPIError
159+
160+
with mock.patch("model_registry.remote_metadata.httpx.get") as get, \
161+
mock.patch("model_registry.remote_metadata.chat_config.UPSTREAM", "http://127.0.0.1:8080/chat"), \
162+
mock.patch("model_registry.remote_metadata.ChatAPI.sign_in") as sign_in:
163+
sign_in.side_effect = ChatAPIError("refused")
164+
result = discover_target_metadata(owui_model(), user=self._make_user())
165+
self.assertEqual(result["status"], "unavailable")
166+
self.assertIsNone(result["metadata"])
167+
get.assert_not_called()
168+
169+
def _make_user(self):
170+
from types import SimpleNamespace as NS
171+
172+
user = NS(email="admin@example.local", username="studio", is_superuser=True)
173+
user.get_full_name = lambda: "Studio Admin"
174+
return user

‎templates/partials/run_detail_body.html‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -832,8 +832,12 @@ <h2 class="text-sm font-semibold text-gray-300 mb-3">Frozen Manifest</h2>
832832
</div>
833833
{% endfor %}
834834
{% if target_metadata_rows %}
835-
<div class="mt-3 pt-3 border-t border-gray-800">
836-
<p class="text-[10px] uppercase tracking-wide text-gray-500 mb-2">Target Metadata</p>
835+
<details class="mt-3 pt-3 border-t border-gray-800 group/md">
836+
<summary class="cursor-pointer list-none flex items-center gap-1.5 select-none [&::-webkit-details-marker]:hidden">
837+
<svg class="w-3 h-3 text-gray-500 transition-transform group-open/md:rotate-90" viewBox="0 0 12 12" fill="currentColor"><path d="M4 2l4 4-4 4V2z"/></svg>
838+
<span class="text-[10px] uppercase tracking-wide text-gray-500">Target Metadata ({{ target_metadata_rows|length }})</span>
839+
</summary>
840+
<div class="mt-2">
837841
{% for field in target_metadata_rows %}
838842
<div class="flex justify-between gap-3 py-1 border-b border-gray-800/40 last:border-0">
839843
<span class="text-gray-500 min-w-0 break-all font-mono">{{ field.key }}</span>
@@ -847,7 +851,8 @@ <h2 class="text-sm font-semibold text-gray-300 mb-3">Frozen Manifest</h2>
847851
{% endif %}
848852
</div>
849853
{% endfor %}
850-
</div>
854+
</div>
855+
</details>
851856
{% endif %}
852857
{% if frozen_agent %}
853858
<div class="mt-3 pt-3 border-t border-gray-800">

0 commit comments

Comments
 (0)