Files
mnemosyne/mnemosyne/mcp_server/api/teams.py
Robert Helewka 93639188d3
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
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
2026-05-23 19:50:29 -04:00

298 lines
10 KiB
Python

"""REST endpoints for the Mnemosyne ↔ Daedalus team control plane.
Mounted under ``/mcp_server/api/teams/`` — see §7 of
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. Every endpoint is
``IsAuthenticated``-gated and scoped to ``request.user``: the user that
created a team via ``POST /`` is the only user that can read, mutate,
or rotate it. Non-owners receive 404 (not 403) so a team's existence
isn't disclosed across users. Endpoints are designed to be idempotent
where possible:
* ``POST /`` — create a team by UUID; a second POST with
the same id returns 200 without a new JWT.
* ``DELETE /{id}/`` — soft-delete (``active=False``). 204 even if
the team was already inactive.
* ``PUT /{id}/workspaces/`` — replace the team's workspace assignment
set; idempotent.
* ``POST /{id}/rotate/`` — generate a new ``active_jti`` and sign a
fresh JWT. Old JWT invalid immediately.
* ``GET /{id}/`` — read-only detail (no JWT).
All mutating endpoints log an audit line at INFO — these are the audit
points ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §13.4 refers to.
"""
from __future__ import annotations
import logging
from django.db import transaction
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from ..models import Team, TeamWorkspaceAssignment
from ..teams import TeamJWTError, mint_team_jwt
from .serializers import (
TeamCreateRequestSerializer,
TeamPublicSerializer,
TeamWorkspacesUpdateSerializer,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_team(team_id, user):
"""Load a team by UUID *for this user* or None.
Returns None both when the team does not exist and when it exists
but is owned by another user. Callers 404 on None — they must not
distinguish the two cases.
"""
return Team.objects.filter(pk=team_id, owner=user).first()
def _mint_with_fresh_jti(team: Team) -> str:
"""Rotate the team's jti and return a freshly-signed JWT."""
team.rotate_jti()
return mint_team_jwt(team)
# ---------------------------------------------------------------------------
# POST /mcp_server/api/teams/
# ---------------------------------------------------------------------------
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def team_create(request):
"""Create a team. Idempotent on ``id``.
* Fresh id → 201 with ``jwt`` surfaced exactly once.
* Existing id → 200 with the team's current state, **no** ``jwt``.
To get a new JWT for an existing team, call ``/rotate/``.
"""
serializer = TeamCreateRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
existing = Team.objects.filter(pk=data["id"]).first()
if existing is not None:
if existing.owner_id != request.user.id:
# Same id, different owner. Surfacing existence would leak
# one user's team id to another; treat as a generic conflict.
logger.warning(
"team_create owner_conflict team_id=%s caller=%s",
existing.id, request.user.id,
)
return Response(
{"detail": "Team id is already in use."},
status=status.HTTP_409_CONFLICT,
)
# Idempotent: surface current state without a new JWT. If the
# caller wants to reactivate a soft-deleted team they must do
# so explicitly via the admin or a future endpoint; re-POST is
# not "undelete" because that would silently return a new JWT
# on every retry storm.
logger.info(
"team_create idempotent_hit team_id=%s name=%s active=%s",
existing.id, existing.name, existing.active,
)
return Response(
TeamPublicSerializer(existing).data, status=status.HTTP_200_OK
)
try:
with transaction.atomic():
team = Team.objects.create(
id=data["id"], name=data["name"], owner=request.user
)
jwt_string = _mint_with_fresh_jti(team)
except TeamJWTError as exc:
# Rolling back the create is fine — we have no signing key yet
# and cannot hand the caller a usable credential.
logger.error(
"team_create mint_failed team_id=%s reason=%s",
data["id"], exc,
)
return Response(
{"detail": str(exc)},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
logger.info(
"team_create created team_id=%s name=%s jti=%s",
team.id, team.name, team.active_jti,
)
payload = TeamPublicSerializer(team).data
payload["jwt"] = jwt_string
return Response(payload, status=status.HTTP_201_CREATED)
# ---------------------------------------------------------------------------
# DELETE /mcp_server/api/teams/{id}/ + GET /mcp_server/api/teams/{id}/
# ---------------------------------------------------------------------------
@api_view(["GET", "DELETE"])
@permission_classes([IsAuthenticated])
def team_detail(request, team_id):
"""Soft-delete the team (``DELETE``) or read its state (``GET``).
The DELETE path sets ``active=False`` and clears ``active_jti`` so
any token still in use is rejected on the next request. Row stays
in the database for audit; call POST ``/`` with the same id to
re-materialize a fresh team if needed (operator decision).
"""
team = _get_team(team_id, request.user)
if team is None:
return Response(
{"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND
)
if request.method == "GET":
return Response(TeamPublicSerializer(team).data)
# DELETE
was_active = team.active
team.deactivate()
logger.info(
"team_delete team_id=%s was_active=%s",
team.id, was_active,
)
return Response(status=status.HTTP_204_NO_CONTENT)
# ---------------------------------------------------------------------------
# PUT /mcp_server/api/teams/{id}/workspaces/
# ---------------------------------------------------------------------------
@api_view(["PUT"])
@permission_classes([IsAuthenticated])
def team_workspaces(request, team_id):
"""Replace the team's workspace assignment set.
Idempotent: the stored set equals ``workspace_ids`` after the call.
Diff is computed in-DB so we don't thrash rows that already match —
only the net-add and net-remove rows are touched.
"""
team = _get_team(team_id, request.user)
if team is None:
return Response(
{"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND
)
serializer = TeamWorkspacesUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
target = set(serializer.validated_data["workspace_ids"])
with transaction.atomic():
current = set(
team.workspace_assignments.values_list("workspace_id", flat=True)
)
to_add = target - current
to_remove = current - target
if to_remove:
team.workspace_assignments.filter(
workspace_id__in=to_remove
).delete()
if to_add:
TeamWorkspaceAssignment.objects.bulk_create(
[
TeamWorkspaceAssignment(team=team, workspace_id=ws)
for ws in to_add
],
ignore_conflicts=True,
)
logger.info(
"team_workspaces_updated team_id=%s added=%d removed=%d total=%d",
team.id, len(to_add), len(to_remove), len(target),
)
return Response(
{"workspace_ids": sorted(target)},
status=status.HTTP_200_OK,
)
# ---------------------------------------------------------------------------
# POST /mcp_server/api/teams/{id}/rotate/
# ---------------------------------------------------------------------------
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def team_rotate(request, team_id):
"""Generate a fresh ``active_jti`` and JWT.
Upsert-on-missing: if no ``Team`` exists for this id, create one
owned by the caller and mint its first JWT. This eliminates the
ordering trap where Daedalus calls rotate before its provisioning
flow has POSTed the team — the operator clicks "Rotate JWT" and
things just work. The placeholder ``name`` is the team id; an
operator can rename later via admin or the create endpoint.
A pre-existing team owned by a *different* user returns 409 (same
shape as ``team_create``'s collision branch) — never disclose
cross-user existence.
The previously-issued JWT stops validating immediately — the auth
middleware compares the incoming ``jti`` against ``Team.active_jti``
on every request.
Does NOT reactivate a soft-deleted team: if ``active=False``,
returns 409 so the operator is forced to go through the explicit
create/readd flow rather than quietly resurrecting a team.
"""
team = Team.objects.filter(pk=team_id).first()
if team is None:
team = Team.objects.create(
id=team_id,
name=str(team_id),
owner=request.user,
)
logger.info(
"team_rotate upserted_missing team_id=%s owner=%s",
team.id, request.user.username,
)
elif team.owner_id != request.user.id:
logger.warning(
"team_rotate owner_conflict team_id=%s caller=%s",
team.id, request.user.id,
)
return Response(
{"detail": "Team id is already in use."},
status=status.HTTP_409_CONFLICT,
)
elif not team.active:
return Response(
{"detail": "Team is inactive; cannot rotate. Recreate it instead."},
status=status.HTTP_409_CONFLICT,
)
try:
jwt_string = _mint_with_fresh_jti(team)
except TeamJWTError as exc:
logger.error(
"team_rotate mint_failed team_id=%s reason=%s",
team.id, exc,
)
return Response(
{"detail": str(exc)},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
logger.info(
"team_rotate team_id=%s new_jti=%s",
team.id, team.active_jti,
)
return Response({"jwt": jwt_string}, status=status.HTTP_200_OK)