- 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
298 lines
10 KiB
Python
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)
|