feat(library): add workspace-scoped search and JWT auth for Daedalus
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 52s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m32s

- 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:
2026-05-03 17:36:06 -04:00
parent e5618973fc
commit a2c885cf34
11 changed files with 555 additions and 48 deletions

View File

@@ -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)

View File

@@ -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: