docs: clarify Daedalus-Pallas integration auth model
Refine the phase-2 integration spec to reflect implementation details: - Change `resolved_libraries` from `set[str]` to ordered `list[str]` - Document `MCPToken.allowed_libraries` as JSONField (not M2M) since Library lives in Neo4j, not Django's ORM - Clarify that `Library.workspace_id` is a content-routing attribute, not an authorization axis - Describe retirement of the three-branch `_WORKSPACE_SCOPE_CLAUSE` in favor of a single `lib.uid IN $resolved_libraries` check - Specify team JWT resolution via `TeamWorkspaceAssignment` DB join - Note admin UI materializes full Library UID list explicitly
This commit is contained in:
@@ -99,6 +99,17 @@ class ImageSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class SearchRequestSerializer(serializers.Serializer):
|
||||
"""Request body for ``/library/api/search/``.
|
||||
|
||||
Authorization scope is resolved server-side from the request's
|
||||
Django session (this endpoint is gated by
|
||||
``permission_classes=[IsAuthenticated]``), not from the request
|
||||
body — see ``library.utils.all_library_uids`` and the unified
|
||||
auth model in ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.3.
|
||||
``library_uid`` / ``library_type`` / ``collection_uid`` are
|
||||
filters inside that scope, not scope itself.
|
||||
"""
|
||||
|
||||
query = serializers.CharField(max_length=2000)
|
||||
library_uid = serializers.CharField(required=False, allow_blank=True)
|
||||
library_type = serializers.ChoiceField(
|
||||
@@ -106,7 +117,6 @@ class SearchRequestSerializer(serializers.Serializer):
|
||||
required=False,
|
||||
)
|
||||
collection_uid = serializers.CharField(required=False, allow_blank=True)
|
||||
workspace_id = serializers.CharField(required=False, allow_blank=True)
|
||||
search_types = serializers.ListField(
|
||||
child=serializers.ChoiceField(choices=["vector", "fulltext", "graph"]),
|
||||
required=False,
|
||||
|
||||
@@ -479,17 +479,23 @@ def search(request):
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
from library.services.search import SearchRequest, SearchService
|
||||
from library.utils import all_library_uids
|
||||
|
||||
serializer = SearchRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
# This DRF endpoint is gated by ``IsAuthenticated`` against a
|
||||
# Django session, not an MCP bearer. The session is trusted;
|
||||
# expose every library to the request. MCP-bearer callers go
|
||||
# through ``mcp_server`` and get a narrower ``resolved_libraries``
|
||||
# materialized by the auth middleware.
|
||||
search_request = SearchRequest(
|
||||
query=data["query"],
|
||||
library_uid=data.get("library_uid") or None,
|
||||
library_type=data.get("library_type") or None,
|
||||
collection_uid=data.get("collection_uid") or None,
|
||||
workspace_id=data.get("workspace_id") or None,
|
||||
resolved_libraries=all_library_uids(),
|
||||
search_types=data.get("search_types", ["vector", "fulltext", "graph"]),
|
||||
limit=data.get("limit", getattr(django_settings, "SEARCH_DEFAULT_LIMIT", 20)),
|
||||
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50),
|
||||
@@ -511,6 +517,7 @@ def search_vector(request):
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
from library.services.search import SearchRequest, SearchService
|
||||
from library.utils import all_library_uids
|
||||
|
||||
serializer = SearchRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
@@ -521,7 +528,7 @@ def search_vector(request):
|
||||
library_uid=data.get("library_uid") or None,
|
||||
library_type=data.get("library_type") or None,
|
||||
collection_uid=data.get("collection_uid") or None,
|
||||
workspace_id=data.get("workspace_id") or None,
|
||||
resolved_libraries=all_library_uids(),
|
||||
search_types=["vector"],
|
||||
limit=data.get("limit", 20),
|
||||
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50),
|
||||
@@ -542,6 +549,7 @@ def search_fulltext(request):
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
from library.services.search import SearchRequest, SearchService
|
||||
from library.utils import all_library_uids
|
||||
|
||||
serializer = SearchRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
@@ -552,7 +560,7 @@ def search_fulltext(request):
|
||||
library_uid=data.get("library_uid") or None,
|
||||
library_type=data.get("library_type") or None,
|
||||
collection_uid=data.get("collection_uid") or None,
|
||||
workspace_id=data.get("workspace_id") or None,
|
||||
resolved_libraries=all_library_uids(),
|
||||
search_types=["fulltext"],
|
||||
limit=data.get("limit", 20),
|
||||
fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30),
|
||||
|
||||
@@ -74,6 +74,11 @@ class Command(BaseCommand):
|
||||
query=query,
|
||||
library_uid=options["library_uid"] or None,
|
||||
library_type=options["library_type"] or None,
|
||||
# Unrestricted: the CLI is a shell-level operator tool; it
|
||||
# bypasses the MCP bearer-resolver and sees every library.
|
||||
# ``resolved_libraries=None`` is the "no auth clause" branch
|
||||
# (see ``library/services/search.py::_RESOLVED_LIBRARIES_CLAUSE``).
|
||||
resolved_libraries=None,
|
||||
search_types=search_types,
|
||||
limit=limit,
|
||||
vector_top_k=getattr(settings, "SEARCH_VECTOR_TOP_K", 50),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.13 on 2026-04-28 12:36
|
||||
# Generated by Django 5.2.13 on 2026-05-10 15:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -26,36 +26,47 @@ from .fusion import ImageSearchResult, SearchCandidate, reciprocal_rank_fusion
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Search-scope clause appended to every search Cypher query.
|
||||
# Search-scope clause appended to every Cypher query.
|
||||
#
|
||||
# Three modes, picked structurally by which params are set:
|
||||
# Authorization is expressed by the caller as a ``resolved_libraries``
|
||||
# list — see §3.3 of ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. The
|
||||
# MCP auth middleware materializes it from the bearer token (opaque
|
||||
# MCPToken.allowed_libraries, per-turn JWT ``libs`` claim, or live
|
||||
# ``Team → TeamWorkspaceAssignment → Library.workspace_id`` join) and
|
||||
# trusted in-process callers (Django admin page, DRF session-auth'd
|
||||
# search endpoint, ``manage.py search``) either pass the full set from
|
||||
# ``library.utils.all_library_uids()`` or pass ``None`` to bypass the
|
||||
# clause entirely.
|
||||
#
|
||||
# 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).
|
||||
# Two Cypher branches, picked by whether ``resolved_libraries`` is the
|
||||
# parameter value:
|
||||
#
|
||||
# When ``allowed_libraries`` is non-empty alone (no workspace_id), it
|
||||
# narrows results to those libraries.
|
||||
_WORKSPACE_SCOPE_CLAUSE = (
|
||||
" 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)"
|
||||
")"
|
||||
)
|
||||
# * ``None`` — no clause; trusted in-process admin / CLI
|
||||
# use. Returns every library the query hits.
|
||||
# * non-empty list — ``WHERE lib.uid IN $resolved_libraries``.
|
||||
# * empty list — fail-closed: no row passes because ``uid IN []``
|
||||
# is false for every row (Cypher semantics).
|
||||
#
|
||||
# ``Library.workspace_id`` is NOT consulted here. It remains on the
|
||||
# node as a Daedalus content-routing attribute (used by the ingest
|
||||
# API and the workspace-lifecycle cascade) but it is not an auth axis.
|
||||
_RESOLVED_LIBRARIES_CLAUSE = " AND ($resolved_libraries IS NULL OR lib.uid IN $resolved_libraries)"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchRequest:
|
||||
"""Parameters for a search query.
|
||||
|
||||
Scope is single-mode: a request is either workspace-scoped (workspace_id
|
||||
set) or global (workspace_id is None). There is no parameter combination
|
||||
that returns both workspace and global content in one call.
|
||||
Authorization scope is expressed by ``resolved_libraries``:
|
||||
|
||||
* ``None`` — unrestricted (trusted admin / CLI callers).
|
||||
* ``[]`` — fail-closed; zero results.
|
||||
* ``["lib_x", …]`` — restrict to these Library UIDs.
|
||||
|
||||
``library_uid`` / ``library_type`` / ``collection_uid`` are
|
||||
orthogonal *filters* supplied by the caller (e.g. "search only
|
||||
within Fiction"); they narrow further inside whatever
|
||||
``resolved_libraries`` already permits.
|
||||
"""
|
||||
|
||||
query: str
|
||||
@@ -64,11 +75,9 @@ class SearchRequest:
|
||||
library_uid: Optional[str] = None
|
||||
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
|
||||
# Authorization-resolved Library UID set. See the module-level
|
||||
# ``_RESOLVED_LIBRARIES_CLAUSE`` docstring for semantics.
|
||||
resolved_libraries: Optional[list[str]] = None
|
||||
search_types: list[str] = field(
|
||||
default_factory=lambda: ["vector", "fulltext", "graph"]
|
||||
)
|
||||
@@ -82,19 +91,17 @@ class SearchRequest:
|
||||
def __post_init__(self):
|
||||
# Normalize empty strings to None so "" doesn't slip through as
|
||||
# truthy at the Cypher boundary.
|
||||
if self.workspace_id == "":
|
||||
self.workspace_id = None
|
||||
if self.library_uid == "":
|
||||
self.library_uid = None
|
||||
if self.library_type == "":
|
||||
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
|
||||
# resolved_libraries: preserve the distinction between None (no
|
||||
# auth clause — trusted caller) and [] (fail-closed). Only
|
||||
# normalize list contents to strip falsy entries.
|
||||
if isinstance(self.resolved_libraries, list):
|
||||
self.resolved_libraries = [u for u in self.resolved_libraries if u]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -347,7 +354,7 @@ class SearchService:
|
||||
AND ($library_type IS NULL OR lib.library_type = $library_type)
|
||||
AND ($collection_uid IS NULL OR col.uid = $collection_uid)
|
||||
"""
|
||||
+ _WORKSPACE_SCOPE_CLAUSE
|
||||
+ _RESOLVED_LIBRARIES_CLAUSE
|
||||
+ """
|
||||
RETURN chunk.uid AS chunk_uid, chunk.text_preview AS text_preview,
|
||||
chunk.chunk_s3_key AS chunk_s3_key, chunk.chunk_index AS chunk_index,
|
||||
@@ -364,8 +371,7 @@ class SearchService:
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"collection_uid": request.collection_uid,
|
||||
"workspace_id": request.workspace_id,
|
||||
"allowed_libraries": request.allowed_libraries,
|
||||
"resolved_libraries": request.resolved_libraries,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -459,7 +465,7 @@ class SearchService:
|
||||
AND ($library_type IS NULL OR lib.library_type = $library_type)
|
||||
AND ($collection_uid IS NULL OR col.uid = $collection_uid)
|
||||
"""
|
||||
+ _WORKSPACE_SCOPE_CLAUSE
|
||||
+ _RESOLVED_LIBRARIES_CLAUSE
|
||||
+ """
|
||||
RETURN chunk.uid AS chunk_uid, chunk.text_preview AS text_preview,
|
||||
chunk.chunk_s3_key AS chunk_s3_key, chunk.chunk_index AS chunk_index,
|
||||
@@ -476,8 +482,7 @@ class SearchService:
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"collection_uid": request.collection_uid,
|
||||
"workspace_id": request.workspace_id,
|
||||
"allowed_libraries": request.allowed_libraries,
|
||||
"resolved_libraries": request.resolved_libraries,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -520,7 +525,7 @@ class SearchService:
|
||||
WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
|
||||
AND ($library_type IS NULL OR lib.library_type = $library_type)
|
||||
"""
|
||||
+ _WORKSPACE_SCOPE_CLAUSE
|
||||
+ _RESOLVED_LIBRARIES_CLAUSE
|
||||
+ """
|
||||
RETURN chunk.uid AS chunk_uid, chunk.text_preview AS text_preview,
|
||||
chunk.chunk_s3_key AS chunk_s3_key, chunk.chunk_index AS chunk_index,
|
||||
@@ -537,8 +542,7 @@ class SearchService:
|
||||
"top_k": top_k,
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"workspace_id": request.workspace_id,
|
||||
"allowed_libraries": request.allowed_libraries,
|
||||
"resolved_libraries": request.resolved_libraries,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -593,7 +597,7 @@ class SearchService:
|
||||
WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
|
||||
AND ($library_type IS NULL OR lib.library_type = $library_type)
|
||||
"""
|
||||
+ _WORKSPACE_SCOPE_CLAUSE
|
||||
+ _RESOLVED_LIBRARIES_CLAUSE
|
||||
+ """
|
||||
WITH chunk, item, lib,
|
||||
max(concept_score) AS score,
|
||||
@@ -613,8 +617,7 @@ class SearchService:
|
||||
"limit": request.fulltext_top_k,
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"workspace_id": request.workspace_id,
|
||||
"allowed_libraries": request.allowed_libraries,
|
||||
"resolved_libraries": request.resolved_libraries,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -682,7 +685,7 @@ class SearchService:
|
||||
WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
|
||||
AND ($library_type IS NULL OR lib.library_type = $library_type)
|
||||
"""
|
||||
+ _WORKSPACE_SCOPE_CLAUSE
|
||||
+ _RESOLVED_LIBRARIES_CLAUSE
|
||||
+ """
|
||||
RETURN img.uid AS image_uid, img.image_type AS image_type,
|
||||
img.description AS description, img.s3_key AS s3_key,
|
||||
@@ -698,8 +701,7 @@ class SearchService:
|
||||
"query_vector": query_vector,
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"workspace_id": request.workspace_id,
|
||||
"allowed_libraries": request.allowed_libraries,
|
||||
"resolved_libraries": request.resolved_libraries,
|
||||
}
|
||||
|
||||
try:
|
||||
|
||||
@@ -21,3 +21,32 @@ def neo4j_available():
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def all_library_uids() -> list[str]:
|
||||
"""Return the UIDs of every ``Library`` node in Neo4j.
|
||||
|
||||
Used by trusted in-process callers — the Django admin HTML search
|
||||
page, the ``/library/api/search/`` DRF endpoint (gated by Django
|
||||
session auth) and the ``search`` management command — as the
|
||||
``resolved_libraries`` argument to :class:`SearchRequest`. These
|
||||
callers have already been authenticated/authorized at a coarser
|
||||
layer (Django login / DRF session) and the unified auth middleware
|
||||
(see ``mcp_server/auth.py``) is the one that resolves narrower
|
||||
library sets for MCP bearer tokens.
|
||||
|
||||
Returns ``[]`` when Neo4j is unreachable. Callers that want the
|
||||
unrestricted / "admin sees everything" semantics should feed this
|
||||
result directly into ``SearchRequest.resolved_libraries``; callers
|
||||
that want to distinguish "unrestricted" from "fail-closed empty"
|
||||
must pass ``resolved_libraries=None`` for the former instead.
|
||||
"""
|
||||
if not neo4j_available():
|
||||
return []
|
||||
try:
|
||||
from .models import Library
|
||||
|
||||
return [lib.uid for lib in Library.nodes.all() if lib.uid]
|
||||
except Exception as exc: # pragma: no cover - Neo4j unreachable paths
|
||||
logger.warning("Failed to enumerate library UIDs for search: %s", exc)
|
||||
return []
|
||||
|
||||
@@ -141,40 +141,16 @@ _MAX_QUERY_IMAGE_BYTES = 8 * 1024 * 1024
|
||||
|
||||
|
||||
def _all_library_uids() -> list[str]:
|
||||
"""Return the UIDs of every Library node in Neo4j.
|
||||
"""Legacy alias for :func:`library.utils.all_library_uids`.
|
||||
|
||||
The Django-side HTML search views (``search_page`` and
|
||||
``library_search``) are admin/debug tools gated by ``@login_required``
|
||||
against a local Django account; they are not exposed to external
|
||||
MCP callers and have no workspace-scoping contract to honour.
|
||||
|
||||
The underlying ``SearchService`` always appends
|
||||
``_WORKSPACE_SCOPE_CLAUSE`` to every Cypher query, and that clause's
|
||||
default branch — "``$workspace_id`` IS NULL AND ``$allowed_libraries``
|
||||
IS NULL" — only matches libraries whose own ``workspace_id`` is
|
||||
``NULL``. So an authenticated admin searching from the UI would
|
||||
silently miss every Daedalus-ingested document, because those
|
||||
libraries always carry a non-null ``workspace_id``.
|
||||
|
||||
Passing the full set of library UIDs as ``allowed_libraries`` flips
|
||||
the clause into its second branch
|
||||
(``lib.uid IN $allowed_libraries``) which matches every library
|
||||
regardless of ``workspace_id``. This reuses the exact mechanism
|
||||
Phase-2 chat turns use for "user-managed libraries"; we're simply
|
||||
granting the admin access to all of them. Returning ``[]`` is fine
|
||||
when Neo4j is unreachable — ``SearchRequest.__post_init__``
|
||||
collapses an empty list to ``None``, reverting to the legacy global
|
||||
behaviour.
|
||||
Kept here so existing tests that patch
|
||||
``library.views._all_library_uids`` continue to work during the
|
||||
Phase-2 refactor. New code should import ``all_library_uids``
|
||||
directly from ``library.utils``.
|
||||
"""
|
||||
if not neo4j_available():
|
||||
return []
|
||||
try:
|
||||
from .models import Library
|
||||
from .utils import all_library_uids
|
||||
|
||||
return [lib.uid for lib in Library.nodes.all() if lib.uid]
|
||||
except Exception as exc: # pragma: no cover - Neo4j unreachable paths
|
||||
logger.warning("Failed to enumerate library UIDs for search: %s", exc)
|
||||
return []
|
||||
return all_library_uids()
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -240,7 +216,7 @@ def library_search(request, uid):
|
||||
query_image=image_bytes,
|
||||
query_image_ext=image_ext,
|
||||
library_uid=uid,
|
||||
allowed_libraries=allowed,
|
||||
resolved_libraries=allowed,
|
||||
limit=getattr(django_settings, "SEARCH_DEFAULT_LIMIT", 20),
|
||||
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50),
|
||||
fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30),
|
||||
@@ -830,11 +806,13 @@ def search_page(request):
|
||||
query=query,
|
||||
library_uid=library_uid or None,
|
||||
library_type=library_type or None,
|
||||
# Admin UI sees everything — workspace-scoped libraries
|
||||
# included. Without this, ``_WORKSPACE_SCOPE_CLAUSE``
|
||||
# falls back to its "global-only" branch and silently
|
||||
# hides all Daedalus-ingested content.
|
||||
allowed_libraries=_all_library_uids(),
|
||||
# Admin UI is session-authenticated and sees every
|
||||
# library, Daedalus-workspace-scoped or global.
|
||||
# ``library.utils.all_library_uids`` materializes the
|
||||
# full Library UID set as the request's
|
||||
# ``resolved_libraries`` — see the unified auth model
|
||||
# in ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.3.
|
||||
resolved_libraries=_all_library_uids(),
|
||||
limit=getattr(django_settings, "SEARCH_DEFAULT_LIMIT", 20),
|
||||
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50),
|
||||
fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30),
|
||||
|
||||
Reference in New Issue
Block a user