Skip to content

Commit cdc4775

Browse files
committed
Fix DLS writes in ingestion paths
1 parent 1268f3d commit cdc4775

5 files changed

Lines changed: 213 additions & 84 deletions

File tree

src/models/processors.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -312,13 +312,15 @@ async def process_document_standard(
312312

313313
dimensions = len(embeddings[0])
314314

315-
# Mapping updates are index-admin operations and fail under document-level
316-
# security when attempted with a user-scoped OpenSearch client.
317-
mapping_client = clients.opensearch or opensearch_client
315+
# Mapping and write operations are index-admin operations and fail under
316+
# document-level security when attempted with a user-scoped OpenSearch
317+
# client. Reads still use the scoped client above so duplicate checks
318+
# respect the caller's visibility.
319+
write_client = clients.opensearch or opensearch_client
318320

319321
# Ensure the embedding field exists for this model
320322
embedding_field_name = await ensure_embedding_field_exists(
321-
mapping_client, embedding_model, get_index_name(), dimensions
323+
write_client, embedding_model, get_index_name(), dimensions
322324
)
323325

324326
# Index each chunk as a separate document
@@ -364,7 +366,7 @@ async def process_document_standard(
364366
chunk_doc["is_sample_data"] = "true"
365367
chunk_id = f"{file_hash}_{i}"
366368
try:
367-
await opensearch_client.index(index=get_index_name(), id=chunk_id, body=chunk_doc)
369+
await write_client.index(index=get_index_name(), id=chunk_id, body=chunk_doc)
368370
except Exception as e:
369371
logger.error(
370372
"OpenSearch indexing failed for chunk",

src/services/knowledge_filter_service.py

Lines changed: 51 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Dict, Optional
1+
from typing import Any
22

33
KNOWLEDGE_FILTERS_INDEX_NAME = "knowledge_filters"
44

@@ -7,15 +7,23 @@ class KnowledgeFilterService:
77
def __init__(self, session_manager=None):
88
self.session_manager = session_manager
99

10+
def _user_client(self, user_id: str = None, jwt_token: str = None):
11+
return self.session_manager.get_user_opensearch_client(user_id, jwt_token)
12+
13+
def _write_client(self, user_id: str = None, jwt_token: str = None):
14+
# OpenSearch rejects write requests on indices protected by filter-level
15+
# DLS. The app enforces ownership/visibility with the scoped client, then
16+
# performs trusted writes with the admin client.
17+
from config.settings import clients
18+
19+
return clients.opensearch or self._user_client(user_id, jwt_token)
20+
1021
async def create_knowledge_filter(
11-
self, filter_doc: Dict[str, Any], user_id: str = None, jwt_token: str = None
12-
) -> Dict[str, Any]:
22+
self, filter_doc: dict[str, Any], user_id: str = None, jwt_token: str = None
23+
) -> dict[str, Any]:
1324
"""Create a new knowledge filter"""
1425
try:
15-
# Get user's OpenSearch client with JWT for OIDC auth
16-
opensearch_client = self.session_manager.get_user_opensearch_client(
17-
user_id, jwt_token
18-
)
26+
opensearch_client = self._write_client(user_id, jwt_token)
1927

2028
# Index the knowledge filter document
2129
result = await opensearch_client.index(
@@ -40,13 +48,10 @@ async def create_knowledge_filter(
4048

4149
async def search_knowledge_filters(
4250
self, query: str, user_id: str = None, jwt_token: str = None, limit: int = 20
43-
) -> Dict[str, Any]:
51+
) -> dict[str, Any]:
4452
"""Search for knowledge filters by name, description, or query content"""
4553
try:
46-
# Get user's OpenSearch client with JWT for OIDC auth
47-
opensearch_client = self.session_manager.get_user_opensearch_client(
48-
user_id, jwt_token
49-
)
54+
opensearch_client = self._user_client(user_id, jwt_token)
5055

5156
if query.strip():
5257
# Search across name, description, and query_data fields
@@ -109,17 +114,12 @@ async def search_knowledge_filters(
109114

110115
async def get_knowledge_filter(
111116
self, filter_id: str, user_id: str = None, jwt_token: str = None
112-
) -> Dict[str, Any]:
117+
) -> dict[str, Any]:
113118
"""Get a specific knowledge filter by ID"""
114119
try:
115-
# Get user's OpenSearch client with JWT for OIDC auth
116-
opensearch_client = self.session_manager.get_user_opensearch_client(
117-
user_id, jwt_token
118-
)
120+
opensearch_client = self._user_client(user_id, jwt_token)
119121

120-
result = await opensearch_client.get(
121-
index=KNOWLEDGE_FILTERS_INDEX_NAME, id=filter_id
122-
)
122+
result = await opensearch_client.get(index=KNOWLEDGE_FILTERS_INDEX_NAME, id=filter_id)
123123

124124
if result.get("found"):
125125
knowledge_filter = result["_source"]
@@ -133,16 +133,17 @@ async def get_knowledge_filter(
133133
async def update_knowledge_filter(
134134
self,
135135
filter_id: str,
136-
updates: Dict[str, Any],
136+
updates: dict[str, Any],
137137
user_id: str = None,
138138
jwt_token: str = None,
139-
) -> Dict[str, Any]:
139+
) -> dict[str, Any]:
140140
"""Update an existing knowledge filter"""
141141
try:
142-
# Get user's OpenSearch client with JWT for OIDC auth
143-
opensearch_client = self.session_manager.get_user_opensearch_client(
144-
user_id, jwt_token
145-
)
142+
existing = await self.get_knowledge_filter(filter_id, user_id, jwt_token)
143+
if not existing.get("success"):
144+
return existing
145+
146+
opensearch_client = self._write_client(user_id, jwt_token)
146147

147148
# Update the document
148149
result = await opensearch_client.update(
@@ -159,10 +160,10 @@ async def update_knowledge_filter(
159160
await opensearch_client.indices.refresh(index=KNOWLEDGE_FILTERS_INDEX_NAME)
160161
except Exception:
161162
pass
162-
updated_doc = await opensearch_client.get(
163-
index=KNOWLEDGE_FILTERS_INDEX_NAME, id=filter_id
164-
)
165-
return {"success": True, "filter": updated_doc["_source"]}
163+
updated_doc = await self.get_knowledge_filter(filter_id, user_id, jwt_token)
164+
if updated_doc.get("success"):
165+
return updated_doc
166+
return {"success": False, "error": "Failed to read updated knowledge filter"}
166167
else:
167168
return {"success": False, "error": "Failed to update knowledge filter"}
168169

@@ -171,13 +172,17 @@ async def update_knowledge_filter(
171172

172173
async def delete_knowledge_filter(
173174
self, filter_id: str, user_id: str = None, jwt_token: str = None
174-
) -> Dict[str, Any]:
175+
) -> dict[str, Any]:
175176
"""Delete a knowledge filter"""
176177
try:
177-
# Get user's OpenSearch client with JWT for OIDC auth
178-
opensearch_client = self.session_manager.get_user_opensearch_client(
179-
user_id, jwt_token
180-
)
178+
existing = await self.get_knowledge_filter(filter_id, user_id, jwt_token)
179+
if not existing.get("success"):
180+
return {
181+
"success": False,
182+
"error": "Knowledge filter not found or access denied",
183+
}
184+
185+
opensearch_client = self._write_client(user_id, jwt_token)
181186

182187
result = await opensearch_client.delete(
183188
index=KNOWLEDGE_FILTERS_INDEX_NAME,
@@ -219,20 +224,14 @@ async def delete_knowledge_filter(
219224
async def add_subscription(
220225
self,
221226
filter_id: str,
222-
subscription_data: Dict[str, Any],
227+
subscription_data: dict[str, Any],
223228
user_id: str = None,
224229
jwt_token: str = None,
225-
) -> Dict[str, Any]:
230+
) -> dict[str, Any]:
226231
"""Add a subscription to a knowledge filter"""
227232
try:
228-
opensearch_client = self.session_manager.get_user_opensearch_client(
229-
user_id, jwt_token
230-
)
231-
232233
# Get the current filter document
233-
filter_result = await self.get_knowledge_filter(
234-
filter_id, user_id, jwt_token
235-
)
234+
filter_result = await self.get_knowledge_filter(filter_id, user_id, jwt_token)
236235
if not filter_result.get("success"):
237236
return filter_result
238237

@@ -246,12 +245,11 @@ async def add_subscription(
246245
update_body = {
247246
"doc": {
248247
"subscriptions": subscriptions,
249-
"updated_at": subscription_data[
250-
"created_at"
251-
], # Use the same timestamp
248+
"updated_at": subscription_data["created_at"], # Use the same timestamp
252249
}
253250
}
254251

252+
opensearch_client = self._write_client(user_id, jwt_token)
255253
result = await opensearch_client.update(
256254
index=KNOWLEDGE_FILTERS_INDEX_NAME,
257255
id=filter_id,
@@ -273,17 +271,11 @@ async def remove_subscription(
273271
subscription_id: str,
274272
user_id: str = None,
275273
jwt_token: str = None,
276-
) -> Dict[str, Any]:
274+
) -> dict[str, Any]:
277275
"""Remove a subscription from a knowledge filter"""
278276
try:
279-
opensearch_client = self.session_manager.get_user_opensearch_client(
280-
user_id, jwt_token
281-
)
282-
283277
# Get the current filter document
284-
filter_result = await self.get_knowledge_filter(
285-
filter_id, user_id, jwt_token
286-
)
278+
filter_result = await self.get_knowledge_filter(filter_id, user_id, jwt_token)
287279
if not filter_result.get("success"):
288280
return filter_result
289281

@@ -292,9 +284,7 @@ async def remove_subscription(
292284
# Remove subscription from the subscriptions array
293285
subscriptions = filter_doc.get("subscriptions", [])
294286
updated_subscriptions = [
295-
sub
296-
for sub in subscriptions
297-
if sub.get("subscription_id") != subscription_id
287+
sub for sub in subscriptions if sub.get("subscription_id") != subscription_id
298288
]
299289

300290
if len(updated_subscriptions) == len(subscriptions):
@@ -310,6 +300,7 @@ async def remove_subscription(
310300
}
311301
}
312302

303+
opensearch_client = self._write_client(user_id, jwt_token)
313304
result = await opensearch_client.update(
314305
index=KNOWLEDGE_FILTERS_INDEX_NAME, id=filter_id, body=update_body
315306
)
@@ -324,12 +315,10 @@ async def remove_subscription(
324315

325316
async def get_filter_subscriptions(
326317
self, filter_id: str, user_id: str = None, jwt_token: str = None
327-
) -> Dict[str, Any]:
318+
) -> dict[str, Any]:
328319
"""Get all subscriptions for a knowledge filter"""
329320
try:
330-
filter_result = await self.get_knowledge_filter(
331-
filter_id, user_id, jwt_token
332-
)
321+
filter_result = await self.get_knowledge_filter(filter_id, user_id, jwt_token)
333322
if not filter_result.get("success"):
334323
return filter_result
335324

tests/integration/core/test_runtime_migration.py

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import asyncio
66
import json
7+
import sys
78
from pathlib import Path
89

910
import httpx
@@ -12,6 +13,31 @@
1213
import yaml
1314
from sqlalchemy import func, select
1415

16+
_RELOAD_MODULES = [
17+
"api",
18+
"api.connector_router",
19+
"api.router",
20+
"app.container",
21+
"app.factory",
22+
"app.lifespan",
23+
"app.routes",
24+
"app.routes.internal",
25+
"auth_middleware",
26+
"config.settings",
27+
"db.engine",
28+
"db.migrations_runtime",
29+
"dependencies",
30+
"main",
31+
"services",
32+
"services.conversation_persistence_service",
33+
"services.default_docs_service",
34+
"services.rbac_service",
35+
"services.search_service",
36+
"services.session_ownership_service",
37+
"services.startup_orchestrator",
38+
"utils.opensearch_init",
39+
]
40+
1541

1642
@pytest_asyncio.fixture
1743
async def legacy_migration_workspace(tmp_path: Path, monkeypatch):
@@ -35,6 +61,12 @@ async def legacy_migration_workspace(tmp_path: Path, monkeypatch):
3561
monkeypatch.setenv("DISABLE_STARTUP_INGEST", "true")
3662
monkeypatch.setenv("FETCH_OPENRAG_DOCS_AT_STARTUP", "false")
3763

64+
from db.engine import dispose_engine as dispose_existing_engine
65+
66+
await dispose_existing_engine()
67+
for mod in _RELOAD_MODULES:
68+
sys.modules.pop(mod, None)
69+
3870
from config.config_manager import config_manager
3971
from db.engine import dispose_engine
4072
from dependencies import invalidate_user_ensured_cache
@@ -80,6 +112,8 @@ async def legacy_migration_workspace(tmp_path: Path, monkeypatch):
80112
session_ownership_service.ownership_file = old_ownership_file
81113
session_ownership_service.ownership_data = old_ownership_data
82114
session_ownership_service._session_factory = old_ownership_session_factory
115+
for mod in _RELOAD_MODULES:
116+
sys.modules.pop(mod, None)
83117

84118

85119
def _write_legacy_files(*, config_dir: Path, data_dir: Path) -> None:
@@ -210,12 +244,8 @@ async def _db_snapshot() -> dict[str, int]:
210244
assert SessionLocal is not None
211245
async with SessionLocal() as session:
212246
users = await session.scalar(select(func.count()).select_from(User))
213-
conversations = await session.scalar(
214-
select(func.count()).select_from(Conversation)
215-
)
216-
ownership = await session.scalar(
217-
select(func.count()).select_from(SessionOwnership)
218-
)
247+
conversations = await session.scalar(select(func.count()).select_from(Conversation))
248+
ownership = await session.scalar(select(func.count()).select_from(SessionOwnership))
219249
statuses = await session.scalar(select(func.count()).select_from(MigrationStatus))
220250
return {
221251
"users": int(users or 0),
@@ -287,18 +317,15 @@ async def test_legacy_file_state_migrates_on_backend_startup_and_is_idempotent()
287317
first_snapshot = await _db_snapshot()
288318

289319
transport = httpx.ASGITransport(app=app)
290-
async with httpx.AsyncClient(
291-
transport=transport, base_url="http://testserver"
292-
) as client:
320+
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
293321
onboarding = await client.get("/onboarding-status")
294322
assert onboarding.status_code == 200, onboarding.text
295323
assert onboarding.json() == {"onboarded": True, "current_step": 4}
296324

297325
history = await client.get("/chat/history")
298326
assert history.status_code == 200, history.text
299327
conversations = {
300-
item["response_id"]: item
301-
for item in history.json().get("conversations", [])
328+
item["response_id"]: item for item in history.json().get("conversations", [])
302329
}
303330
assert conversations["legacy-session"]["title"] == "Migrated chat"
304331
assert conversations["legacy-session"]["total_messages"] == 3
@@ -310,9 +337,7 @@ async def test_legacy_file_state_migrates_on_backend_startup_and_is_idempotent()
310337
try:
311338
assert await _db_snapshot() == first_snapshot
312339
transport = httpx.ASGITransport(app=app)
313-
async with httpx.AsyncClient(
314-
transport=transport, base_url="http://testserver"
315-
) as client:
340+
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
316341
onboarding = await client.get("/onboarding-status")
317342
assert onboarding.status_code == 200, onboarding.text
318343
assert onboarding.json()["onboarded"] is True

0 commit comments

Comments
 (0)