feat: rework auth model with UserToken and Daedalus/Pallas integration
- 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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user