docs: clarify Daedalus-Pallas integration auth model
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 51s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m27s

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:
2026-05-10 11:59:44 -04:00
parent e9f6eeb1a3
commit 16fb7ff4dc
35 changed files with 1839 additions and 2035 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 []

View File

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