Skip to content

Commit c17779c

Browse files
committed
fix(kb): scope collection rename by tenant and block shared-name conflicts
Reject non-admin renames when multiple users share a collection name, require admin target_user_id for scoped renames, and align metadata/config updates with per-user vector renames to prevent cross-tenant control-plane corruption.
1 parent 72a6c7b commit c17779c

6 files changed

Lines changed: 749 additions & 38 deletions

File tree

src/xagent/core/tools/core/RAG_tools/storage/contracts.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -334,16 +334,36 @@ async def delete_collection_metadata(
334334
"""Delete persisted metadata/config rows for a collection."""
335335

336336
@abstractmethod
337-
async def rename_collection(self, old_name: str, new_name: str) -> None:
338-
"""Rename persisted control-plane keys after a data-plane collection rename.
337+
async def count_users_with_collection_config(self, collection_name: str) -> int:
338+
"""Count distinct users with a ``collection_config`` row for ``collection_name``.
339339
340-
Updates rows that gate :meth:`list_collections` visibility (for example
341-
per-tenant config rows and aggregate metadata) so they stay aligned with
342-
vector tables when the ``collection`` / ``name`` fields change.
340+
Args:
341+
collection_name: Collection name (sanitized by the caller).
342+
343+
Returns:
344+
Number of distinct ``user_id`` values occupying the name.
345+
"""
346+
347+
@abstractmethod
348+
async def rename_collection(
349+
self,
350+
old_name: str,
351+
new_name: str,
352+
*,
353+
user_id: int,
354+
is_admin: bool = False,
355+
) -> None:
356+
"""Rename persisted control-plane keys for one user's collection scope.
357+
358+
Updates the caller's ``collection_config`` row. Global ``collection_metadata``
359+
is renamed only when this user is the sole config occupant of ``old_name``.
343360
344361
Args:
345362
old_name: Previous collection name (sanitized by the caller).
346363
new_name: Target collection name (sanitized by the caller).
364+
user_id: User whose config (and optionally metadata) should be renamed.
365+
is_admin: Whether the operation is performed by an admin on behalf of
366+
``user_id`` (does not broaden vector/config scope beyond ``user_id``).
347367
"""
348368

349369
@abstractmethod

src/xagent/core/tools/core/RAG_tools/storage/lancedb_stores.py

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -80,42 +80,94 @@ async def delete_collection(self, collection_name: str) -> None:
8080
except Exception as exc:
8181
logger.debug("Failed to delete collection metadata: %s", exc)
8282

83-
async def rename_collection(self, old_name: str, new_name: str) -> None:
84-
"""Rename ``collection_config`` and ``collection_metadata`` keys.
83+
async def count_users_with_collection_config(self, collection_name: str) -> int:
84+
"""Count distinct ``user_id`` rows in ``collection_config`` for a collection name."""
85+
from ..LanceDB.schema_manager import (
86+
_safe_close_table,
87+
ensure_collection_config_table,
88+
)
89+
90+
conn = await self._get_connection()
91+
ensure_collection_config_table(conn)
92+
93+
safe_collection = escape_lancedb_string(collection_name)
94+
config_table = None
95+
try:
96+
config_table = conn.open_table("collection_config")
97+
rows = (
98+
config_table.search()
99+
.where(f"collection = '{safe_collection}'")
100+
.to_arrow()
101+
.to_pylist()
102+
)
103+
except Exception as exc: # noqa: BLE001
104+
logger.debug(
105+
"Failed to count collection config occupants for %s: %s",
106+
collection_name,
107+
exc,
108+
)
109+
return 0
110+
finally:
111+
_safe_close_table(config_table)
112+
113+
occupant_ids = {
114+
int(row["user_id"]) for row in rows if row.get("user_id") is not None
115+
}
116+
return len(occupant_ids)
117+
118+
async def rename_collection(
119+
self,
120+
old_name: str,
121+
new_name: str,
122+
*,
123+
user_id: int,
124+
is_admin: bool = False,
125+
) -> None:
126+
"""Rename control-plane keys for a single user's collection scope.
85127
86128
See :meth:`MetadataStore.rename_collection`.
87129
"""
130+
from ..core.schemas import CollectionInfo
88131
from ..LanceDB.schema_manager import (
89132
_safe_close_table,
90133
ensure_collection_config_table,
91134
)
92135

136+
del (
137+
is_admin
138+
) # Scope is always limited to ``user_id``; flag is for callers only.
139+
93140
conn = await self._get_connection()
94141
await self.ensure_collection_metadata_table()
95142
ensure_collection_config_table(conn)
96143

97144
safe_old = escape_lancedb_string(old_name)
98145
now = datetime.now(timezone.utc).replace(tzinfo=None)
146+
occupant_count = await self.count_users_with_collection_config(old_name)
99147

100148
config_table = None
101149
try:
102150
config_table = conn.open_table("collection_config")
103151
config_table.update(
104-
f"collection = '{safe_old}'",
152+
f"collection = '{safe_old}' AND user_id = {int(user_id)}",
105153
{"collection": new_name, "updated_at": now},
106154
)
107155
finally:
108156
_safe_close_table(config_table)
109157

110-
meta_table = None
111-
try:
112-
meta_table = conn.open_table("collection_metadata")
113-
meta_table.update(
114-
f"name = '{safe_old}'",
115-
{"name": new_name, "updated_at": now},
116-
)
117-
finally:
118-
_safe_close_table(meta_table)
158+
if occupant_count <= 1:
159+
meta_table = None
160+
try:
161+
meta_table = conn.open_table("collection_metadata")
162+
meta_table.update(
163+
f"name = '{safe_old}'",
164+
{"name": new_name, "updated_at": now},
165+
)
166+
finally:
167+
_safe_close_table(meta_table)
168+
return
169+
170+
await self.save_collection(CollectionInfo(name=new_name))
119171

120172
async def save_collection(self, collection: CollectionInfo) -> None:
121173
from ..LanceDB.schema_manager import _safe_close_table

src/xagent/web/api/kb.py

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@
7777
)
7878
from ...core.tools.core.RAG_tools.progress import get_progress_manager
7979
from ...core.tools.core.RAG_tools.storage.contracts import DocumentRecord
80-
from ...core.tools.core.RAG_tools.storage.factory import get_vector_index_store
80+
from ...core.tools.core.RAG_tools.storage.factory import (
81+
get_metadata_store,
82+
get_vector_index_store,
83+
)
8184
from ...core.tools.core.RAG_tools.utils.string_utils import (
8285
generate_deterministic_doc_id,
8386
)
@@ -3916,6 +3919,10 @@ def _resolve_list_documents_match() -> Optional[ResolvedDocumentMatch]:
39163919
async def rename_collection_api(
39173920
collection_name: str,
39183921
new_name: str = Form(..., description="New collection name"),
3922+
target_user_id: Optional[int] = Form(
3923+
None,
3924+
description="Admin only: user whose collection scope to rename",
3925+
),
39193926
_user: User = Depends(get_current_user),
39203927
db: Session = Depends(get_db),
39213928
) -> dict:
@@ -3924,6 +3931,7 @@ async def rename_collection_api(
39243931
Args:
39253932
collection_name: Current collection name
39263933
new_name: New collection name
3934+
target_user_id: Required for admins; scopes rename to that user's data only
39273935
39283936
Returns:
39293937
Success message
@@ -3933,13 +3941,20 @@ async def rename_collection_api(
39333941
load_ingestion_status,
39343942
write_ingestion_status,
39353943
)
3936-
from ...core.tools.core.RAG_tools.storage.factory import (
3937-
get_metadata_store,
3938-
get_vector_index_store,
3939-
)
39403944

3945+
metadata_store = get_metadata_store()
39413946
vector_store = get_vector_index_store()
39423947

3948+
if bool(_user.is_admin):
3949+
if target_user_id is None:
3950+
raise HTTPException(
3951+
status_code=422,
3952+
detail="Admin rename requires target_user_id form field",
3953+
)
3954+
scope_user_id = int(target_user_id)
3955+
else:
3956+
scope_user_id = int(_user.id)
3957+
39433958
if not new_name or not new_name.strip():
39443959
raise HTTPException(
39453960
status_code=422,
@@ -3961,12 +3976,40 @@ async def rename_collection_api(
39613976
if safe_new_collection == safe_old_collection:
39623977
return {"status": "success", "message": "Collection name unchanged"}
39633978

3979+
is_admin_rename = bool(_user.is_admin)
3980+
if not is_admin_rename:
3981+
occupant_count = await metadata_store.count_users_with_collection_config(
3982+
safe_old_collection
3983+
)
3984+
if occupant_count > 1:
3985+
raise HTTPException(
3986+
status_code=409,
3987+
detail=(
3988+
"Collection name is shared by multiple users; "
3989+
"rename is not allowed. Contact an administrator."
3990+
),
3991+
)
3992+
39643993
# Access control check
3965-
await _ensure_collection_access(safe_old_collection, _user, hide_missing=False)
3994+
if is_admin_rename:
3995+
visible_for_scope = await _list_collections_with_retry(
3996+
user_id=scope_user_id,
3997+
is_admin=False,
3998+
stage="rename_list_visible_collections_for_target_user",
3999+
)
4000+
if not any(
4001+
c.name == safe_old_collection for c in visible_for_scope.collections
4002+
):
4003+
raise HTTPException(
4004+
status_code=404,
4005+
detail=f"Collection not found for user: {safe_old_collection}",
4006+
)
4007+
else:
4008+
await _ensure_collection_access(safe_old_collection, _user, hide_missing=False)
39664009

39674010
# Validate that target collection doesn't exist or user has access
39684011
visible_for_user = await _list_collections_with_retry(
3969-
user_id=int(_user.id),
4012+
user_id=scope_user_id,
39704013
is_admin=False,
39714014
stage="rename_list_visible_collections",
39724015
)
@@ -3993,8 +4036,8 @@ async def rename_collection_api(
39934036
new_collection_dir: Optional[Path] = None
39944037
collection_records = vector_store.list_document_records(
39954038
collection_name=safe_old_collection,
3996-
user_id=int(_user.id),
3997-
is_admin=bool(_user.is_admin),
4039+
user_id=scope_user_id,
4040+
is_admin=False,
39984041
)
39994042
collection_file_ids = {
40004043
file_id
@@ -4006,7 +4049,7 @@ async def rename_collection_api(
40064049

40074050
physical_rename = rename_collection_storage(
40084051
db,
4009-
user_id=int(_user.id),
4052+
user_id=scope_user_id,
40104053
old_collection_name=safe_old_collection,
40114054
new_collection_name=safe_new_collection,
40124055
collection_file_ids=collection_file_ids,
@@ -4031,30 +4074,33 @@ async def rename_collection_api(
40314074
)
40324075

40334076
# Step 2: Update collection name in all tables (documents, parses, chunks, embeddings)
4034-
# Use storage abstraction layer which handles all tables including embeddings
4035-
vector_store = get_vector_index_store()
40364077
warnings.extend(
40374078
vector_store.rename_collection_data(
40384079
collection_name=safe_old_collection,
40394080
new_name=safe_new_collection,
4040-
user_id=int(_user.id),
4041-
is_admin=bool(_user.is_admin),
4081+
user_id=scope_user_id,
4082+
is_admin=False,
40424083
)
40434084
)
40444085

40454086
try:
4046-
metadata_store = get_metadata_store()
40474087
await metadata_store.rename_collection(
40484088
old_name=safe_old_collection,
40494089
new_name=safe_new_collection,
4090+
user_id=scope_user_id,
4091+
is_admin=is_admin_rename,
40504092
)
40514093
except Exception as e:
40524094
logger.warning("Failed to rename metadata store keys: %s", e)
40534095
warnings.append(f"Failed to rename collection metadata: {e}")
40544096

40554097
# Migrate ingestion status from old collection name to new
40564098
try:
4057-
status_entries = load_ingestion_status(collection=safe_old_collection)
4099+
status_entries = load_ingestion_status(
4100+
collection=safe_old_collection,
4101+
user_id=scope_user_id,
4102+
is_admin=False,
4103+
)
40584104
for entry in status_entries:
40594105
doc_id = entry.get("doc_id")
40604106
if doc_id:
@@ -4064,8 +4110,14 @@ async def rename_collection_api(
40644110
status=entry.get("status", "pending"),
40654111
message=entry.get("message", ""),
40664112
parse_hash=entry.get("parse_hash", ""),
4113+
user_id=scope_user_id,
4114+
)
4115+
clear_ingestion_status(
4116+
safe_old_collection,
4117+
doc_id,
4118+
user_id=scope_user_id,
4119+
is_admin=False,
40674120
)
4068-
clear_ingestion_status(safe_old_collection, doc_id)
40694121
except Exception as e:
40704122
logger.warning("Failed to update ingestion status: %s", e)
40714123
warnings.append(f"Failed to update ingestion status: {e}")

tests/core/tools/core/RAG_tools/management/test_collections.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,3 +1067,57 @@ def test_delete_collection_removes_metadata_table_entry(temp_lancedb_dir: str) -
10671067
after_table = conn.open_table("collection_metadata")
10681068
after = after_table.search().where(f"name = '{collection}'").to_list()
10691069
assert after == []
1070+
1071+
1072+
@pytest.mark.asyncio
1073+
async def test_metadata_rename_collection_preserves_other_users_config_when_shared(
1074+
temp_lancedb_dir: str,
1075+
) -> None:
1076+
"""Tenant-scoped metadata rename must not retarget another user's config row."""
1077+
from src.xagent.core.tools.core.RAG_tools.core.schemas import CollectionInfo
1078+
1079+
collection = "shared_rename"
1080+
store = get_metadata_store()
1081+
await store.save_collection(CollectionInfo(name=collection))
1082+
await store.save_collection_config(collection, "{}", user_id=1)
1083+
await store.save_collection_config(collection, "{}", user_id=2)
1084+
1085+
await store.rename_collection(
1086+
collection,
1087+
"user1_new",
1088+
user_id=1,
1089+
is_admin=False,
1090+
)
1091+
1092+
assert await store.get_collection_config("user1_new", user_id=1) == "{}"
1093+
assert await store.get_collection_config(collection, user_id=2) == "{}"
1094+
assert await store.get_collection_config("user1_new", user_id=2) is None
1095+
1096+
listed = await list_collections(user_id=None, is_admin=True)
1097+
names = {info.name for info in listed.collections}
1098+
assert collection in names
1099+
assert "user1_new" in names
1100+
1101+
1102+
@pytest.mark.asyncio
1103+
async def test_metadata_rename_collection_admin_scopes_to_target_user_only(
1104+
temp_lancedb_dir: str,
1105+
) -> None:
1106+
"""Admin rename for one user must leave other occupants on the old name."""
1107+
from src.xagent.core.tools.core.RAG_tools.core.schemas import CollectionInfo
1108+
1109+
collection = "shared_admin_rename"
1110+
store = get_metadata_store()
1111+
await store.save_collection(CollectionInfo(name=collection))
1112+
await store.save_collection_config(collection, "{}", user_id=10)
1113+
await store.save_collection_config(collection, "{}", user_id=20)
1114+
1115+
await store.rename_collection(
1116+
collection,
1117+
"user10_new",
1118+
user_id=10,
1119+
is_admin=True,
1120+
)
1121+
1122+
assert await store.get_collection_config("user10_new", user_id=10) == "{}"
1123+
assert await store.get_collection_config(collection, user_id=20) == "{}"

0 commit comments

Comments
 (0)