Skip to content

Commit a43f140

Browse files
Match connector_file_id in ACL/metadata updates (#1704)
Ensure ACL and metadata updates target both Langflow and non-Langflow chunks by matching document IDs against multiple fields. Introduce _build_id_query and id_fields parameters to should_update_acl, update_document_acl, and batch_update_acls so updates can match document_id and connector_file_id. Stop overwriting chunk owner during ACL syncs (only allowed_users/allowed_groups are updated) and set owner at ingest to the syncing user in TaskProcessor. Adjust tests to expect the combined bool query and make minor typing/import/formatting cleanups.
1 parent 742afc7 commit a43f140

4 files changed

Lines changed: 102 additions & 51 deletions

File tree

src/connectors/service.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,15 @@ async def _update_connector_metadata(
191191
owner_user_id, jwt_token
192192
)
193193

194-
# Update ACL if changed (hash-based skip optimization)
194+
# Update ACL if changed (hash-based skip optimization).
195+
# Match both document_id and connector_file_id: non-Langflow connector
196+
# chunks store the connector id in connector_file_id (document_id holds the
197+
# content hash), while Langflow chunks store it in document_id.
195198
acl_result = await update_document_acl(
196199
document_id=document.id,
197200
acl=document.acl,
198201
opensearch_client=opensearch_client,
202+
id_fields=("document_id", "connector_file_id"),
199203
)
200204

201205
# Log ACL update result
@@ -214,7 +218,18 @@ async def _update_connector_metadata(
214218
await opensearch_client.update_by_query(
215219
index=self.index_name,
216220
body={
217-
"query": {"term": {"document_id": document.id}},
221+
# Match both fields: non-Langflow chunks carry the connector id
222+
# in connector_file_id (document_id is the content hash),
223+
# Langflow chunks carry it in document_id.
224+
"query": {
225+
"bool": {
226+
"should": [
227+
{"term": {"document_id": document.id}},
228+
{"term": {"connector_file_id": document.id}},
229+
],
230+
"minimum_should_match": 1,
231+
}
232+
},
218233
"script": {
219234
"source": """
220235
ctx._source.source_url = params.source_url;

src/models/processors.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -368,15 +368,19 @@ async def process_document_standard(
368368
if connector_file_id:
369369
chunk_doc["connector_file_id"] = connector_file_id
370370

371-
# Set owner and ACL fields
371+
# Set owner and ACL fields.
372+
# owner is always the syncing/uploading user (matching the Langflow
373+
# pipeline, which indexes owner from the OWNER global var = user id).
374+
# acl.owner (e.g. the SharePoint file's author) is intentionally NOT
375+
# used here — read access comes from allowed_users/allowed_groups + DLS,
376+
# while owner gates deletion and "my documents" ownership.
377+
chunk_doc["owner"] = owner_user_id
372378
if acl:
373-
# Use ACL data if provided (from connector)
374-
chunk_doc["owner"] = acl.owner if acl.owner else owner_user_id
379+
# Use ACL access lists if provided (from connector)
375380
chunk_doc["allowed_users"] = acl.allowed_users
376381
chunk_doc["allowed_groups"] = acl.allowed_groups
377382
else:
378-
# Fallback to owner_user_id if no ACL (local uploads)
379-
chunk_doc["owner"] = owner_user_id
383+
# No ACL provided
380384
chunk_doc["allowed_users"] = []
381385
chunk_doc["allowed_groups"] = []
382386

src/utils/acl_utils.py

Lines changed: 63 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
to minimize write amplification when ACLs change.
66
"""
77

8+
import asyncio
89
import hashlib
910
import json
10-
import asyncio
11-
from typing import Dict, List, Tuple, Optional
11+
1212
from src.connectors.base import DocumentACL
1313
from utils.logging_config import get_logger
1414

@@ -30,15 +30,32 @@ def compute_acl_hash(acl: DocumentACL) -> str:
3030
"allowed_users": sorted(acl.allowed_users),
3131
"allowed_groups": sorted(acl.allowed_groups),
3232
}
33-
return hashlib.sha256(
34-
json.dumps(acl_data, sort_keys=True).encode()
35-
).hexdigest()
33+
return hashlib.sha256(json.dumps(acl_data, sort_keys=True).encode()).hexdigest()
34+
35+
36+
def _build_id_query(document_id: str, id_fields: tuple[str, ...]) -> dict:
37+
"""Build an OpenSearch query matching ``document_id`` against one or more fields.
38+
39+
Non-Langflow connector chunks store the connector source id in
40+
``connector_file_id`` while ``document_id`` holds the content hash, whereas
41+
Langflow chunks and local uploads store the id in ``document_id``. Matching
42+
multiple fields lets a single id reliably target chunks from either pipeline.
43+
"""
44+
if len(id_fields) == 1:
45+
return {"term": {id_fields[0]: document_id}}
46+
return {
47+
"bool": {
48+
"should": [{"term": {field: document_id}} for field in id_fields],
49+
"minimum_should_match": 1,
50+
}
51+
}
3652

3753

3854
async def should_update_acl(
3955
document_id: str,
4056
new_acl: DocumentACL,
41-
opensearch_client
57+
opensearch_client,
58+
id_fields: tuple[str, ...] = ("document_id",),
4259
) -> bool:
4360
"""
4461
Check if ACL has changed by querying one chunk and comparing hashes.
@@ -59,14 +76,14 @@ async def should_update_acl(
5976
response = await opensearch_client.search(
6077
index="documents",
6178
body={
62-
"query": {"term": {"document_id": document_id}},
79+
"query": _build_id_query(document_id, id_fields),
6380
"size": 1,
6481
"_source": [
6582
"owner",
6683
"allowed_users",
6784
"allowed_groups",
68-
]
69-
}
85+
],
86+
},
7087
)
7188

7289
if not response["hits"]["hits"]:
@@ -96,24 +113,34 @@ async def should_update_acl(
96113
async def update_document_acl(
97114
document_id: str,
98115
acl: DocumentACL,
99-
opensearch_client
100-
) -> Dict[str, any]:
116+
opensearch_client,
117+
id_fields: tuple[str, ...] = ("document_id",),
118+
) -> dict[str, any]:
101119
"""
102120
Update ACL for all chunks of a document.
103121
104122
Uses hash-based skip optimization: queries one chunk to check if ACL changed,
105123
only updates if changed. When updating, uses bulk update_by_query for efficiency.
106124
125+
Only the access lists (``allowed_users``/``allowed_groups``) are updated — the
126+
``owner`` field is intentionally left untouched so an ACL re-sync never
127+
reassigns document ownership (owner is set once at ingest to the syncing user).
128+
107129
Args:
108130
document_id: Document identifier
109131
acl: New ACL to apply
110132
opensearch_client: OpenSearch client instance
133+
id_fields: Chunk fields to match ``document_id`` against. Defaults to
134+
``("document_id",)``; pass ``("document_id", "connector_file_id")`` for
135+
connector chunks where the connector id lives in ``connector_file_id``.
111136
112137
Returns:
113138
Dict with status ("unchanged" or "updated") and chunks_updated count
114139
"""
115140
# Check if ACL changed (queries one chunk)
116-
should_update = await should_update_acl(document_id, acl, opensearch_client)
141+
should_update = await should_update_acl(
142+
document_id, acl, opensearch_client, id_fields=id_fields
143+
)
117144

118145
if not should_update:
119146
return {"status": "unchanged", "chunks_updated": 0}
@@ -123,36 +150,32 @@ async def update_document_acl(
123150
response = await opensearch_client.update_by_query(
124151
index="documents",
125152
body={
126-
"query": {"term": {"document_id": document_id}},
153+
"query": _build_id_query(document_id, id_fields),
127154
"script": {
128155
"source": """
129-
ctx._source.owner = params.owner;
130156
ctx._source.allowed_users = params.allowed_users;
131157
ctx._source.allowed_groups = params.allowed_groups;
132158
""",
133159
"params": {
134-
"owner": acl.owner,
135160
"allowed_users": acl.allowed_users,
136161
"allowed_groups": acl.allowed_groups,
137-
}
138-
}
139-
}
162+
},
163+
},
164+
},
140165
)
141166

142-
return {
143-
"status": "updated",
144-
"chunks_updated": response.get("updated", 0)
145-
}
167+
return {"status": "updated", "chunks_updated": response.get("updated", 0)}
146168

147169
except Exception as e:
148170
logger.error("[OPENSEARCH] ACL update failed", document_id=document_id, error=str(e))
149171
return {"status": "error", "chunks_updated": 0, "error": str(e)}
150172

151173

152174
async def batch_update_acls(
153-
acl_updates: List[Tuple[str, DocumentACL]],
154-
opensearch_client
155-
) -> Dict[str, any]:
175+
acl_updates: list[tuple[str, DocumentACL]],
176+
opensearch_client,
177+
id_fields: tuple[str, ...] = ("document_id",),
178+
) -> dict[str, any]:
156179
"""
157180
Batch update ACLs for multiple documents.
158181
@@ -161,9 +184,14 @@ async def batch_update_acls(
161184
- Skip unchanged ACLs (95%+ of webhook notifications)
162185
- Parallel bulk updates for changed ACLs
163186
187+
Only ``allowed_users``/``allowed_groups`` are updated; ``owner`` is left
188+
untouched (see :func:`update_document_acl`).
189+
164190
Args:
165191
acl_updates: List of (document_id, acl) tuples
166192
opensearch_client: OpenSearch client instance
193+
id_fields: Chunk fields to match the id against (see
194+
:func:`update_document_acl`).
167195
168196
Returns:
169197
Dict with status, documents_updated count, and chunks_updated count
@@ -173,15 +201,15 @@ async def batch_update_acls(
173201

174202
# Filter to only changed ACLs (parallel chunk queries)
175203
check_tasks = [
176-
should_update_acl(doc_id, acl, opensearch_client)
204+
should_update_acl(doc_id, acl, opensearch_client, id_fields=id_fields)
177205
for doc_id, acl in acl_updates
178206
]
179207
should_update_flags = await asyncio.gather(*check_tasks)
180208

181209
# Filter to documents with changed ACLs
182210
changed = [
183211
(doc_id, acl)
184-
for (doc_id, acl), should_update in zip(acl_updates, should_update_flags)
212+
for (doc_id, acl), should_update in zip(acl_updates, should_update_flags, strict=True)
185213
if should_update
186214
]
187215

@@ -190,28 +218,26 @@ async def batch_update_acls(
190218
"status": "no_changes",
191219
"documents_updated": 0,
192220
"chunks_updated": 0,
193-
"skipped": len(acl_updates)
221+
"skipped": len(acl_updates),
194222
}
195223

196224
# Bulk update chunks for each document (parallelized)
197225
update_tasks = [
198226
opensearch_client.update_by_query(
199227
index="documents",
200228
body={
201-
"query": {"term": {"document_id": doc_id}},
229+
"query": _build_id_query(doc_id, id_fields),
202230
"script": {
203231
"source": """
204-
ctx._source.owner = params.owner;
205232
ctx._source.allowed_users = params.allowed_users;
206233
ctx._source.allowed_groups = params.allowed_groups;
207234
""",
208235
"params": {
209-
"owner": acl.owner,
210236
"allowed_users": acl.allowed_users,
211237
"allowed_groups": acl.allowed_groups,
212-
}
213-
}
214-
}
238+
},
239+
},
240+
},
215241
)
216242
for doc_id, acl in changed
217243
]
@@ -222,7 +248,7 @@ async def batch_update_acls(
222248
# Count successful updates
223249
total_chunks_updated = 0
224250
errors = []
225-
for i, result in enumerate(results):
251+
for result in results:
226252
if isinstance(result, Exception):
227253
errors.append(str(result))
228254
else:
@@ -233,13 +259,8 @@ async def batch_update_acls(
233259
"documents_updated": len(changed) - len(errors),
234260
"chunks_updated": total_chunks_updated,
235261
"skipped": len(acl_updates) - len(changed),
236-
"errors": errors if errors else None
262+
"errors": errors if errors else None,
237263
}
238264

239265
except Exception as e:
240-
return {
241-
"status": "error",
242-
"documents_updated": 0,
243-
"chunks_updated": 0,
244-
"error": str(e)
245-
}
266+
return {"status": "error", "documents_updated": 0, "chunks_updated": 0, "error": str(e)}

tests/unit/connectors/test_update_connector_metadata_filename.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,19 @@ async def _noop_acl(**_kwargs):
7979
"the new name written through to indexed chunks"
8080
)
8181
assert params["filename"] == "Report-FY26.docx"
82-
# Sanity: scope is by document_id (the stable connector ID).
83-
assert body["query"] == {"term": {"document_id": "graph-item-id-stable"}}
82+
# Scope matches the stable connector ID against BOTH document_id (Langflow
83+
# chunks) and connector_file_id (non-Langflow chunks, whose document_id is
84+
# the content hash). Without the connector_file_id clause, non-Langflow
85+
# chunks never get source_url/filename/timestamps re-applied.
86+
assert body["query"] == {
87+
"bool": {
88+
"should": [
89+
{"term": {"document_id": "graph-item-id-stable"}},
90+
{"term": {"connector_file_id": "graph-item-id-stable"}},
91+
],
92+
"minimum_should_match": 1,
93+
}
94+
}
8495

8596

8697
@pytest.mark.asyncio

0 commit comments

Comments
 (0)