feat: rework auth model with UserToken and Daedalus/Pallas integration
Some checks failed
CVE Scan & Docker Build / build-and-push (push) Has been cancelled
CVE Scan & Docker Build / security-scan (push) Has been cancelled
Build & Deploy Docs / build-and-deploy (push) Successful in 1m10s

- Rename MCPToken to UserToken across models, views, and tests
- Update URL names from mcp-token-* to token-*
- Add Daedalus/Pallas integration design doc (v2)
- Switch docker-compose to build local mnemosyne:local image via shared
  build config instead of pulling from git.helu.ca
This commit is contained in:
2026-05-23 19:50:29 -04:00
parent 735eb9de1a
commit 93639188d3
44 changed files with 1305 additions and 865 deletions

View File

@@ -686,6 +686,29 @@ def concept_graph(request, uid):
# ---------------------------------------------------------------------------
def _job_for_user_or_none(job_id, username):
"""Load an ``IngestJob`` visible to ``username``, else ``None``.
Visibility: the job's ``library_uid`` must resolve to a Library with
``owner_username`` either null (global) or matching ``username``.
Callers translate ``None`` into a 404 with generic wording — cross-
user reads must not disclose existence.
"""
from library.models import IngestJob, Library
try:
job = IngestJob.objects.get(pk=job_id)
except IngestJob.DoesNotExist:
return None
try:
lib = Library.nodes.get(uid=job.library_uid)
except Library.DoesNotExist:
return None
if lib.owner_username and lib.owner_username != username:
return None
return job
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def ingest_create(request):
@@ -733,6 +756,17 @@ def ingest_create(request):
status=status.HTTP_404_NOT_FOUND,
)
# --- Owner-scope (workspace-scoped libraries only) ---
# Global libraries (owner_username is null) stay shared; workspace
# libraries are visible only to their creating user. Cross-user
# callers get the same wording as the not-found branch above so the
# endpoint doesn't disclose existence across users.
if lib.owner_username and lib.owner_username != request.user.username:
return Response(
{"detail": f"Workspace '{workspace_id or library_uid}' not registered."},
status=status.HTTP_404_NOT_FOUND,
)
# --- Idempotency check on (library, source_ref, content_hash) ---
source_ref = data.get("source_ref") or ""
content_hash = data["content_hash"]
@@ -804,11 +838,8 @@ def ingest_create(request):
@permission_classes([IsAuthenticated])
def ingest_job_detail(request, job_id):
"""Get the current status of an IngestJob."""
from library.models import IngestJob
try:
job = IngestJob.objects.get(pk=job_id)
except IngestJob.DoesNotExist:
job = _job_for_user_or_none(job_id, request.user.username)
if job is None:
return Response(
{"detail": "Job not found."}, status=status.HTTP_404_NOT_FOUND
)
@@ -820,12 +851,10 @@ def ingest_job_detail(request, job_id):
@permission_classes([IsAuthenticated])
def ingest_job_retry(request, job_id):
"""Re-dispatch a failed IngestJob."""
from library.models import IngestJob
from library.tasks import ingest_from_daedalus
try:
job = IngestJob.objects.get(pk=job_id)
except IngestJob.DoesNotExist:
job = _job_for_user_or_none(job_id, request.user.username)
if job is None:
return Response(
{"detail": "Job not found."}, status=status.HTTP_404_NOT_FOUND
)
@@ -852,10 +881,18 @@ def ingest_job_retry(request, job_id):
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def ingest_job_list(request):
"""List recent IngestJob rows, optionally filtered by status / library_uid."""
from library.models import IngestJob
"""List recent IngestJob rows, optionally filtered by status / library_uid.
qs = IngestJob.objects.all()
Scoped to libraries the caller owns (plus global libraries that have
no ``owner_username``). A ``library_uid`` query param the caller has
no access to silently returns an empty list — same wording as a
not-found job.
"""
from library.models import IngestJob
from library.utils import library_uids_for_user
visible_uids = library_uids_for_user(request.user.username)
qs = IngestJob.objects.filter(library_uid__in=visible_uids)
status_filter = request.query_params.get("status")
library_uid = request.query_params.get("library_uid")
limit = min(int(request.query_params.get("limit", 50)), 200)

View File

@@ -6,7 +6,8 @@ It uses the same Library node as a global library; the difference is that
`workspace_id` is set, and search must filter on it.
These endpoints are called by the Daedalus backend authenticated as the
Mnemosyne user the workspace belongs to (per-user DRF token). The
Mnemosyne user the workspace belongs to (per-user ``UserToken``,
``Authorization: Bearer <plaintext>``, minted at ``/profile/tokens/``). The
workspace's owning user is recorded on the Library node as
``owner_username``; every read and mutation is scoped to that user.
Non-owners receive 404 so a workspace's existence isn't disclosed

View File

@@ -31,7 +31,7 @@ logger = logging.getLogger(__name__)
# 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
# UserToken.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

View File

@@ -23,6 +23,34 @@ def neo4j_available():
return False
def library_uids_for_user(username: str) -> set[str]:
"""Return the UIDs of every Library this user may read on the REST surface.
A Library is visible to ``username`` if it has no ``owner_username``
(global / shared) or its ``owner_username`` matches. Used by the
ingest job endpoints to filter rows to the calling user's
workspaces — mirrors the owner-scoping on
``/library/api/workspaces/`` and ``/mcp_server/api/teams/``.
Returns the empty set when Neo4j is unreachable (fail-closed).
"""
if not neo4j_available():
return set()
try:
from neomodel import db
rows, _ = db.cypher_query(
"MATCH (l:Library) "
"WHERE l.owner_username IS NULL OR l.owner_username = $u "
"RETURN l.uid",
{"u": username},
)
return {r[0] for r in rows if r[0]}
except Exception as exc: # pragma: no cover - Neo4j unreachable paths
logger.warning("Failed to enumerate library UIDs for user %s: %s", username, exc)
return set()
def all_library_uids() -> list[str]:
"""Return the UIDs of every ``Library`` node in Neo4j.