feat(library): add workspace-scoped search and JWT auth for Daedalus
- Extend library list endpoint with `include_workspace` and `with_item_count` query params to support Daedalus registry mirroring - Expand search scope clause to three modes: workspace-only, workspace plus allowed user libraries, and global - Add `allowed_libraries` field to SearchRequest for Phase-2 JWT claims - Introduce JWT-based actor resolution using a synthetic service user (`MCP_JWT_SERVICE_USERNAME`) for Daedalus-originated requests
This commit is contained in:
@@ -40,13 +40,44 @@ logger = logging.getLogger(__name__)
|
||||
@api_view(["GET", "POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def library_list_create(request):
|
||||
"""List all libraries or create a new one."""
|
||||
"""List all libraries or create a new one.
|
||||
|
||||
GET supports ``?include_workspace=false`` (default ``true``) to filter out
|
||||
libraries that belong to a Daedalus workspace — the Daedalus library
|
||||
registry uses this to mirror only the user-managed catalog.
|
||||
|
||||
GET supports ``?with_item_count=true`` (default ``false``) to attach
|
||||
a per-library ``item_count``. Off by default because the count is a
|
||||
Cypher aggregate; on for the Daedalus-side registry poll.
|
||||
"""
|
||||
from library.models import Library
|
||||
|
||||
if request.method == "GET":
|
||||
libraries = Library.nodes.order_by("name")
|
||||
serializer = LibrarySerializer(libraries, many=True)
|
||||
return Response(serializer.data)
|
||||
include_workspace = request.GET.get("include_workspace", "true").lower() != "false"
|
||||
with_count = request.GET.get("with_item_count", "false").lower() == "true"
|
||||
|
||||
if include_workspace:
|
||||
libraries = list(Library.nodes.order_by("name"))
|
||||
else:
|
||||
libraries = list(Library.nodes.filter(workspace_id__isnull=True).order_by("name"))
|
||||
|
||||
data = LibrarySerializer(libraries, many=True).data
|
||||
|
||||
if with_count and libraries:
|
||||
from neomodel import db
|
||||
|
||||
uids = [lib.uid for lib in libraries]
|
||||
rows, _ = db.cypher_query(
|
||||
"MATCH (l:Library) WHERE l.uid IN $uids "
|
||||
"OPTIONAL MATCH (l)-[:CONTAINS]->(:Collection)-[:CONTAINS]->(i:Item) "
|
||||
"RETURN l.uid, count(i)",
|
||||
{"uids": uids},
|
||||
)
|
||||
counts = {uid: count for (uid, count) in rows}
|
||||
for entry in data:
|
||||
entry["item_count"] = counts.get(entry["uid"], 0)
|
||||
|
||||
return Response(data)
|
||||
|
||||
# POST — create
|
||||
serializer = LibrarySerializer(data=request.data)
|
||||
|
||||
@@ -26,14 +26,26 @@ from .fusion import ImageSearchResult, SearchCandidate, reciprocal_rank_fusion
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Workspace scoping clause appended to every search Cypher query.
|
||||
# Search-scope clause appended to every search Cypher query.
|
||||
#
|
||||
# A request with workspace_id set returns ONLY that workspace's content.
|
||||
# A request with workspace_id null returns ONLY global content (libraries
|
||||
# with no workspace_id). There is no third mode.
|
||||
# Three modes, picked structurally by which params are set:
|
||||
#
|
||||
# 1. ``workspace_id`` set, ``allowed_libraries`` empty → workspace-scoped.
|
||||
# Returns ONLY content from libraries whose workspace_id matches.
|
||||
# 2. ``workspace_id`` set + ``allowed_libraries`` non-empty → workspace
|
||||
# PLUS the listed user-managed libraries (typical Phase-2 chat turn).
|
||||
# 3. Both null → global. Returns ONLY libraries with no workspace_id
|
||||
# (legacy opaque-token callers / dashboard).
|
||||
#
|
||||
# When ``allowed_libraries`` is non-empty alone (no workspace_id), it
|
||||
# narrows results to those libraries.
|
||||
_WORKSPACE_SCOPE_CLAUSE = (
|
||||
" AND ($workspace_id IS NULL AND lib.workspace_id IS NULL OR "
|
||||
"lib.workspace_id = $workspace_id)"
|
||||
" AND ("
|
||||
"($workspace_id IS NOT NULL AND lib.workspace_id = $workspace_id) "
|
||||
"OR ($allowed_libraries IS NOT NULL AND lib.uid IN $allowed_libraries) "
|
||||
"OR ($workspace_id IS NULL AND $allowed_libraries IS NULL "
|
||||
" AND lib.workspace_id IS NULL)"
|
||||
")"
|
||||
)
|
||||
|
||||
|
||||
@@ -52,6 +64,10 @@ class SearchRequest:
|
||||
library_type: Optional[str] = None
|
||||
collection_uid: Optional[str] = None
|
||||
workspace_id: Optional[str] = None
|
||||
# Phase-2 token claim: user-managed libraries the caller may include
|
||||
# alongside their workspace's auto-library. Cypher uses ``IS NULL`` vs
|
||||
# non-empty list to gate the second branch of the scope clause.
|
||||
allowed_libraries: Optional[list[str]] = None
|
||||
search_types: list[str] = field(
|
||||
default_factory=lambda: ["vector", "fulltext", "graph"]
|
||||
)
|
||||
@@ -73,6 +89,11 @@ class SearchRequest:
|
||||
self.library_type = None
|
||||
if self.collection_uid == "":
|
||||
self.collection_uid = None
|
||||
# Empty list collapses to None so the Cypher branch reads
|
||||
# "$allowed_libraries IS NOT NULL" rather than "size > 0" — keeps
|
||||
# the parameter binding straightforward and the predicate sargable.
|
||||
if self.allowed_libraries is not None and len(self.allowed_libraries) == 0:
|
||||
self.allowed_libraries = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -300,6 +321,7 @@ class SearchService:
|
||||
"library_type": request.library_type,
|
||||
"collection_uid": request.collection_uid,
|
||||
"workspace_id": request.workspace_id,
|
||||
"allowed_libraries": request.allowed_libraries,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -411,6 +433,7 @@ class SearchService:
|
||||
"library_type": request.library_type,
|
||||
"collection_uid": request.collection_uid,
|
||||
"workspace_id": request.workspace_id,
|
||||
"allowed_libraries": request.allowed_libraries,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -471,6 +494,7 @@ class SearchService:
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"workspace_id": request.workspace_id,
|
||||
"allowed_libraries": request.allowed_libraries,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -546,6 +570,7 @@ class SearchService:
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"workspace_id": request.workspace_id,
|
||||
"allowed_libraries": request.allowed_libraries,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -630,6 +655,7 @@ class SearchService:
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"workspace_id": request.workspace_id,
|
||||
"allowed_libraries": request.allowed_libraries,
|
||||
}
|
||||
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user